diff --git a/assistants/project-assistant/.cspell/custom-dictionary-workspace.txt b/assistants/project-assistant/.cspell/custom-dictionary-workspace.txt new file mode 100644 index 000000000..255951c23 --- /dev/null +++ b/assistants/project-assistant/.cspell/custom-dictionary-workspace.txt @@ -0,0 +1 @@ +agentic diff --git a/assistants/project-assistant/.vscode/settings.json b/assistants/project-assistant/.vscode/settings.json index 39a26c721..c9cf50d36 100644 --- a/assistants/project-assistant/.vscode/settings.json +++ b/assistants/project-assistant/.vscode/settings.json @@ -26,7 +26,6 @@ "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true }, - "python.analysis.autoFormatStrings": true, "python.analysis.autoImportCompletions": true, "python.analysis.diagnosticMode": "workspace", "python.analysis.fixAll": ["source.unusedImports"], @@ -42,7 +41,6 @@ "source.formatDocument": "explicit" } }, - "ruff.nativeServer": "on", "search.exclude": { "**/.venv": true, "**/.data": true, @@ -58,6 +56,10 @@ "uv.lock" ], "cSpell.words": [ + "addopts", + "agentic", + "asctime", + "asyncio", "Codespaces", "contentsafety", "debugpy", @@ -68,13 +70,16 @@ "Excalidraw", "fastapi", "GIPHY", + "insightfully", "jsonschema", "Langchain", + "levelname", "modelcontextprotocol", "moderations", "mzxrai", "openai", "pdfplumber", + "pycache", "pydantic", "pyproject", "pyright", @@ -83,6 +88,7 @@ "semanticworkbenchteam", "tiktoken", "updown", + "venv", "virtualenvs", "webresearch" ] diff --git a/assistants/project-assistant/CLAUDE.md b/assistants/project-assistant/CLAUDE.md index b68114c61..d35027dd4 100644 --- a/assistants/project-assistant/CLAUDE.md +++ b/assistants/project-assistant/CLAUDE.md @@ -2,28 +2,170 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -# Semantic Workbench Developer Guidelines - -## Common Commands -* Build/Install: `make install` (recursive for all subdirectories) -* Format: `make format` (runs ruff formatter) -* Lint: `make lint` (runs ruff linter) -* Type-check: `make type-check` (runs pyright) -* Test: `make test` (runs pytest) -* Single test: `uv run pytest tests/test_file.py::test_function -v` - -## Code Style -### Python -* Indentation: 4 spaces -* Line length: 120 characters -* Imports: stdlib → third-party → local, alphabetized within groups -* Naming: `snake_case` for functions/variables, `CamelCase` for classes, `UPPER_SNAKE_CASE` for constants -* Types: Use type annotations consistently; prefer Union syntax (`str | None`) for Python 3.10+ -* Documentation: Triple-quote docstrings with param/return descriptions - -## Tools -* Python: Uses uv for environment/dependency management -* Linting/Formatting: Ruff (Python) -* Type checking: Pyright (Python) -* Testing: pytest (Python) -* Package management: uv (Python)Ok. \ No newline at end of file +# Knowledge Transfer Assistant + +A sophisticated dual-mode assistant that facilitates collaborative knowledge sharing between Coordinators (knowledge creators) and Team members (knowledge receivers) within the Semantic Workbench platform. + +## Core Architecture + +### Dual-Mode Operation +The assistant operates in two distinct modes with role-specific capabilities: + +1. **Coordinator Mode**: Creates and manages knowledge packages, responds to information requests, shares files +2. **Team Mode**: Accesses shared knowledge, requests information, tracks learning progress + +### Cross-Conversation Communication +The system manages three types of conversations: +- **Coordinator Conversation**: Personal workspace for knowledge creation +- **Shareable Team Conversation**: Template conversation (never directly used) +- **Team Conversation(s)**: Individual conversations for each team member + +### Key Components + +- **Knowledge Transfer Manager** (`assistant/domain/`): Orchestrates the entire knowledge transfer lifecycle +- **Share Manager** (`assistant/files.py`): Handles file synchronization across conversations +- **Storage System** (`assistant/storage.py`, `assistant/storage_models.py`): Persistent state management +- **UI Tabs** (`assistant/ui-tabs/`): Real-time visual dashboards showing transfer status +- **Notification System** (`assistant/notifications.py`): Cross-conversation communication + +### Core Artifacts +- **Knowledge Brief**: Introductory overview for team members +- **Knowledge Digest**: Auto-updating LLM-generated information repository +- **Learning Objectives**: Structured goals with specific outcomes +- **Information Requests**: Team member questions with priority levels + +## Development Commands + +### Basic Operations +```bash +# Install dependencies +make install + +# Run all tests +make test + +# Run specific test with verbose output +uv run pytest tests/test_file.py::test_function -v + +# Manual inspector test +python tests/test_inspector.py + +# Type checking +make type-check + +# Code quality +make lint +make format +``` + +### Assistant Management +```bash +# Start assistant service +make start + +# Docker operations +make docker-build +make docker-run-local +``` + +## Project Structure + +### Core Implementation (`/assistant/`) +- `assistant.py`: Main assistant with dual-role event handling +- `config.py`: Role-specific prompt templates and configuration +- `storage.py` & `storage_models.py`: Persistent state management +- `conversation_share_link.py`: Cross-conversation linking and synchronization +- `files.py`: File synchronization via ShareFilesManager +- `respond.py`: Response generation logic +- `common.py`: Role detection and common utilities + +### Agentic (`/assistant/agentic/`) +- `team_welcome.py`: Team welcome message generation +- `coordinator_support.py`: Coordinator guidance and support +- `analysis.py`: Analysis functionality + +### Domain Logic (`/assistant/domain/`) +- `share_manager.py`: Share creation, joining, and cross-conversation coordination (`ShareManager` class) +- `knowledge_brief_manager.py`: Brief creation and management (`KnowledgeBriefManager` class) +- `knowledge_digest_manager.py`: Auto-updating digest system (`KnowledgeDigestManager` class) +- `learning_objectives_manager.py`: Learning goal tracking (`LearningObjectivesManager` class) +- `information_request_manager.py`: Team question handling (`InformationRequestManager` class) +- `audience_manager.py`: Audience definition and management (`AudienceManager` class) + +### Tools (`/assistant/tools/`) +- `information_requests.py`: Information request handling +- `learning_objectives.py`: Learning objective management +- `learning_outcomes.py`: Outcome tracking +- `progress_tracking.py`: Transfer progress monitoring +- `share_setup.py`: Share link creation + +### UI Tabs (`/assistant/ui-tabs/`) +- `brief.py`: Knowledge transfer status dashboard +- `learning.py`: Learning objectives tracking +- `sharing.py`: Sharing status monitoring +- `debug.py`: Debug information panel + +### Configuration (`/assistant/text_includes/`) +Role-specific prompts and instruction templates: +- `coordinator_role.txt` & `coordinator_instructions.txt`: Coordinator mode configuration +- `team_role.txt` & `team_instructions.txt`: Team mode configuration +- `knowledge_digest_prompt.txt`: LLM prompts for digest generation + +## Key Dependencies + +- `semantic-workbench-assistant`: Core assistant framework +- `assistant-extensions[attachments]`: File attachment support with dashboard cards +- `content-safety`: Content moderation capabilities +- `openai-client`: LLM integration for knowledge digest generation + +## Development Guidelines + +### Code Philosophy +The project follows a "wabi-sabi" philosophy emphasizing: +- Ruthless simplicity with minimal abstractions +- Present-moment focus rather than future-proofing +- Trust in emergence from simple, well-defined components +- Direct library integration with minimal wrappers + +### Code Style +- Use 4 spaces for indentation +- Maximum line length: 120 characters +- Follow PEP 8 naming conventions +- Use type annotations consistently +- Write docstrings for functions and classes + +### Quality Assurance +Always run the full quality check before submitting changes: +```bash +make lint && make type-check && make test +``` + +### Important Development Notes +- Use `uv` for all Python script execution to ensure correct environment +- Keep ConversationRole enum consistent across all files +- Use Optional typing for parameters that might be None +- Update tests when changing functionality - never skip or remove tests +- Access logs in `.data/logs/` directory (timestamped, latest sorted last) +- Never make git commits - leave that to QA review process + +## Testing + +The test suite covers: +- Artifact loading and management (`test_artifact_loading.py`) +- Inspector functionality (`test_inspector.py`) +- File sharing and synchronization (`test_share_manager.py`) +- Storage system operations (`test_share_storage.py`) +- Tool functionality (`test_share_tools.py`) +- Team mode operations (`test_team_mode.py`) + +Run tests with: +```bash +# All tests +make test + +# Specific test file +uv run pytest tests/test_share_manager.py -v + +# Single test function +uv run pytest tests/test_share_manager.py::test_file_sync -v +``` \ No newline at end of file diff --git a/assistants/project-assistant/README.md b/assistants/project-assistant/README.md index 2b63e2813..99e93b653 100644 --- a/assistants/project-assistant/README.md +++ b/assistants/project-assistant/README.md @@ -1,52 +1,62 @@ -# Project Assistant +# Knowledge Transfer Assistant -A dual-mode context transfer system that facilitates collaborative projects between Coordinators and Team members in the Semantic Workbench. +A dual-mode context transfer system that facilitates collaborative knowledge sharing between Coordinators and Team members in the Semantic Workbench. ## Overview -The Project Assistant is designed to bridge the information gap between project Coordinators and Team members by providing a structured communication system with shared artifacts, real-time updates, and bidirectional information flow. It enables: +The Knowledge Transfer Assistant is designed to bridge the information gap between Coordinators and Team members by providing a structured communication system with shared artifacts, real-time updates, and bidirectional information flow. It enables: -- **Project Definition**: Coordinators can create detailed project briefs with goals and success criteria -- **Information Sharing**: Knowledge transfer between separate conversations -- **Information Requests**: Team members can request information or assistance from Coordinators -- **Progress Tracking**: Real-time project dashboard updates and completion criteria -- **Inspector Panel**: Visual dashboard showing project state and progress +- **Knowledge Organization**: Coordinators can structure and organize complex information for sharing +- **Dual-Mode Operation**: Single assistant with context-aware Coordinator and Team modes +- **Information Sharing**: Knowledge transfer between separate conversations with automatic synchronization +- **Information Requests**: Bidirectional communication system for team member questions +- **Progress Tracking**: Real-time knowledge transfer dashboard updates and completion tracking +- **Inspector Panels**: Multiple specialized visual dashboards showing knowledge transfer state, learning objectives, and debug information + +## Terminology + +- **share**: The space enveloping all of the coordinator and team data. +- **knowledge package**: The information to be transferred from the coordinator(s) to team. +- **knowledge transfer**: The process of transferring knowledge from the coordinator(s) to team. +- **assistant mode**: Whether the assistant is currently in helping-coordinator or helping-team-member mode. ## Key Features -### Conversation Types and Dual Mode Operation +### Conversation Types and Dual Mode Operation -The Project Assistant creates and manages three distinct types of conversations: +The Knowledge Transfer Assistant creates and manages three distinct types of conversations: -1. **Coordinator Conversation**: The personal conversation used by the project coordinator/owner to create and manage the project. +1. **Coordinator Conversation**: The personal conversation used by the knowledge transfer coordinator/owner to create and manage the knowledge base. 2. **Shareable Team Conversation**: A template conversation that's automatically created along with a share URL. This conversation is never directly used - it serves as the template for creating individual team conversations when users click the share link. -3. **Team Conversation(s)**: Individual conversations for team members, created when they redeem the share URL. Each team member gets their own personal conversation connected to the project. +3. **Team Conversation(s)**: Individual conversations for team members, created when they redeem the share URL. Each team member gets their own personal conversation connected to the knowledge transfer. The assistant operates in two distinct modes with different capabilities: -1. **Coordinator Mode (Planning Stage)** - - Create project briefs with clear goals and success criteria - - Maintain an auto-updating project whiteboard with critical information +1. **Coordinator Mode** + - Create and organize knowledge briefs with learning objectives + - Maintain an auto-updating knowledge digest with critical information - Provide guidance and respond to information requests - - Control the "Ready for Working" milestone when project definition is complete + - Share files and context with team members + - Manage knowledge transfer completion tracking -2. **Team Mode (Working Stage)** - - Access project brief and project whiteboard - - Mark success criteria as completed - - Log requests for information or assistance from Coordinators - - Update project dashboard with progress information - - Report project completion when all criteria are met +2. **Team Mode** + - Access knowledge brief and knowledge digest + - Request information or assistance from Coordinators + - Update knowledge transfer status with progress information + - Synchronize shared files from the coordinator + - Explore knowledge share context and learning objectives ### Key Artifacts -The system manages several core artifacts that support project operations: +The system manages several core artifacts that support knowledge transfer operations: -- **Project Brief**: Details project goals and success criteria -- **Project Whiteboard**: Dynamically updated information repository that captures key project context -- **Information Requests**: Documented information needs from Team members -- **Project Dashboard**: Real-time progress tracking and state information +- **Project Brief**: Details knowledge goals and success criteria +- **Knowledge Digest**: Dynamically updated information repository that captures key knowledge share context +- **Learning Objectives**: Structured goals with specific learning outcomes +- **Information Requests**: Documented information needs from Team members with priority levels +- **Project Dashboard**: Real-time progress tracking and state information across multiple inspector panels ### State Management @@ -54,72 +64,80 @@ The assistant uses a multi-layered state management approach: - **Cross-Conversation Linking**: Connects Coordinator and Team conversations - **File Synchronization**: Automatic file sharing between conversations, including when files are uploaded by Coordinators or when team members return to a conversation -- **Inspector Panel**: Real-time visual status dashboard for project progress +- **Inspector Panel**: Real-time visual status dashboard for knowledge transfer progress - **Conversation-Specific Storage**: Each conversation maintains role-specific state ## Usage -### Commands - -#### Common Commands -- `/status` - View current project status and progress -- `/info [brief|whiteboard|requests|all]` - View project information - -#### Coordinator Commands -- `/create-project | ` - Create a new project -- `/add-goal | | [criteria1;criteria2;...]` - Add a project goal -- `/add-kb-section | <content>` - Add whiteboard content manually -- `/ready-for-working` - Mark project as ready for team operations -- `/invite` - Generate project invitation for team members -- `/resolve <request-id> | <resolution>` - Resolve an information request - -#### Team Commands -- `/join <invitation-code>` - Join an existing project -- `/request-info <title> | <description> | [priority]` - Create information request -- `/update-status <status> | <progress> | <message>` - Update project status -- `/complete-criteria <goal-index> <criteria-index>` - Mark criterion as complete -- `/complete-project` - Report project completion ### Workflow 1. **Coordinator Preparation**: - - Create project brief with goals and success criteria - - The project whiteboard automatically updates with key information - - Generate invitation link for team members - - Mark project as ready for working + - Create knowledge brief with learning objectives and outcomes + - The knowledge digest automatically updates with key information from conversations + - Share invitation link with team members + - Upload relevant files for team access + - Define knowledge transfer audience and organize knowledge structure 2. **Team Operations**: - - Join project using invitation link - - Review project brief and whiteboard content - - Execute project tasks and track progress - - Create information requests when information is needed - - Mark criteria as completed when achieved - - Report project completion when all goals are met + - Join the knowledge transfer using invitation link + - Review knowledge brief and knowledge digest content + - Request additional information with priority levels + - Update knowledge transfer status with progress information + - Synchronize files from coordinator automatically 3. **Collaborative Cycle**: - - Coordinator responds to information requests - - Team updates project status with progress - - Both sides can view project status and progress via inspector panel + - Coordinator responds to information requests with detailed resolutions + - Team updates knowledge transfer status with progress tracking + - Both sides can view knowledge transfer status and progress via multiple inspector panels + - Real-time synchronization of knowledge transfer state across all conversations ## Development ### Project Structure - `/assistant/`: Core implementation files - - `chat.py`: Main assistant implementation with event handlers - - `project_tools.py`: Tool functions for the LLM to use - - `state_inspector.py`: Inspector panel implementation - - `project_manager.py`: Project state and artifact management - - `artifact_messaging.py`: Cross-conversation artifact sharing - - `command_processor.py`: Command handling logic + - `assistant.py`: Main assistant implementation with dual-role event handling + - `manager.py`: Project state and artifact management (KnowledgeTransferManager) + - `conversation_share_link.py`: Cross-conversation linking and synchronization + - `storage.py` & `storage_models.py`: Persistent state management + - `config.py`: Role-specific prompt templates and configuration + - `tools.py`: Assistant tools and LLM functions + - `files.py`: File synchronization and management (ShareManager) + - `notifications.py`: Cross-conversation notification system + - `data.py`: Data models for knowledge transfer entities + - `conversation_clients.py`: Conversation client management + - `analysis.py`: Analysis functionality + - `team_welcome.py`: Team welcome message generation + - `utils.py`: General utility functions + - `string_utils.py`: String utility functions + - `common.py`: Common utilities and role detection + - `respond.py`: Response generation + - `logging.py`: Logging configuration + - `inspectors/`: Inspector panel components + - `brief.py`: Brief inspector for knowledge transfer status + - `learning.py`: Learning objectives inspector + - `sharing.py`: Sharing status inspector + - `debug.py`: Debug inspector + - `common.py`: Common inspector utilities + - `text_includes/`: Role-specific prompts and instruction templates + - `assets/`: SVG icons and visual assets - `/docs/`: Documentation files - `DESIGN.md`: System design and architecture - `DEV_GUIDE.md`: Development guidelines + - `JTBD.md`: Jobs-to-be-done analysis - `ASSISTANT_LIBRARY_NOTES.md`: Notes on the assistant library - `WORKBENCH_NOTES.md`: Workbench state management details + - `notable_claude_conversations/`: Archived design conversations -- `/tests/`: Test files covering key functionality +- `/tests/`: Comprehensive test suite + - `test_artifact_loading.py`: Artifact loading and management tests + - `test_inspector.py`: State inspector functionality tests + - `test_share_manager.py`: File sharing and synchronization tests + - `test_share_storage.py`: Storage system tests + - `test_share_tools.py`: Tool functionality tests + - `test_team_mode.py`: Team mode operation tests ### Development Commands @@ -130,21 +148,52 @@ make install # Run tests make test +# Single test with verbose output +uv run pytest tests/test_file.py::test_function -v + +# Manual inspector test +python tests/test_inspector.py + # Type checking make type-check -# Linting +# Linting and formatting make lint -``` +make format -## Architecture +# Docker operations +make docker-build +make docker-run-local -The Project Assistant leverages the Semantic Workbench Assistant library for core functionality and extends it with: +# Start assistant service +make start +``` -1. **Cross-Conversation Communication**: Using the conversation sharing API -2. **Artifact Management**: Structured data models for project information -3. **State Inspection**: Real-time project status dashboard -4. **Tool-based Interaction**: LLM functions for project tasks -5. **Role-Specific Experiences**: Tailored interfaces for Coordinator and Team roles +## Architecture -The system follows a centralized artifact storage model with event-driven updates to keep all conversations synchronized. +The Knowledge Transfer Assistant leverages the Semantic Workbench Assistant library and extends it with: + +### Key Dependencies +- `semantic-workbench-assistant`: Core assistant framework +- `assistant-extensions[attachments]`: File attachment support with dashboard cards +- `content-safety`: Content moderation capabilities +- `openai-client`: LLM integration for knowledge digest generation + +### Architectural Components +1. **Cross-Conversation Communication**: Advanced conversation sharing and synchronization +2. **Artifact Management**: Structured data models for briefs, objectives, and requests +3. **Multi-Panel State Inspection**: Specialized inspector panels for different knowledge transfer aspects +4. **Tool-based Interaction**: Comprehensive LLM functions for knowledge transfer operations +5. **Role-Specific Experiences**: Context-aware interfaces for Coordinator and Team modes +6. **Auto-Updating Knowledge Digest**: LLM-powered automatic extraction of key information +7. **File Synchronization**: Automatic file sharing and synchronization across conversations + +### Design Philosophy +The system follows a **wabi-sabi philosophy** emphasizing: +- Ruthless simplicity with minimal abstractions +- Present-moment focus rather than future-proofing +- Trust in emergence from simple, well-defined components +- Direct library integration with minimal wrappers +- Pragmatic trust in external systems + +The architecture uses a centralized artifact storage model with event-driven updates and real-time UI synchronization to keep all conversations coordinated. diff --git a/assistants/project-assistant/assistant/agentic/__init__.py b/assistants/project-assistant/assistant/agentic/__init__.py new file mode 100644 index 000000000..8c387d466 --- /dev/null +++ b/assistants/project-assistant/assistant/agentic/__init__.py @@ -0,0 +1,25 @@ +from types import SimpleNamespace + +from .act import act +from .coordinator_next_action import get_coordinator_next_action_suggestion +from .create_invitation import create_invitation +from .detect_audience_and_takeaways import detect_audience_and_takeaways +from .detect_coordinator_actions import detect_coordinator_actions +from .detect_information_request_needs import detect_information_request_needs +from .detect_knowledge_package_gaps import detect_knowledge_package_gaps +from .focus import focus +from .team_welcome import generate_team_welcome_message +from .update_digest import update_digest + +agentic = SimpleNamespace( + act=act, + create_invitation=create_invitation, + detect_audience_and_takeaways=detect_audience_and_takeaways, + detect_coordinator_actions=detect_coordinator_actions, + detect_information_request_needs=detect_information_request_needs, + detect_knowledge_package_gaps=detect_knowledge_package_gaps, + focus=focus, + generate_team_welcome_message=generate_team_welcome_message, + get_coordinator_next_action_suggestion=get_coordinator_next_action_suggestion, + update_digest=update_digest, +) diff --git a/assistants/project-assistant/assistant/agentic/act.py b/assistants/project-assistant/assistant/agentic/act.py new file mode 100644 index 000000000..cf3136843 --- /dev/null +++ b/assistants/project-assistant/assistant/agentic/act.py @@ -0,0 +1,214 @@ +from textwrap import dedent +from typing import Any, ClassVar + +import openai_client +from assistant_extensions.attachments import AttachmentsExtension +from openai import BaseModel +from openai_client import num_tokens_from_messages +from openai_client.errors import CompletionError +from openai_client.tools import complete_with_tool_calls +from pydantic import ConfigDict, Field +from semantic_workbench_api_model.workbench_model import ( + MessageType, + NewConversationMessage, +) +from semantic_workbench_assistant.assistant_app import ( + ConversationContext, +) + +from assistant.config import assistant_config +from assistant.data import InformationRequestSource, InspectorTab, NewInformationRequest +from assistant.domain.information_request_manager import InformationRequestManager +from assistant.domain.share_manager import ShareManager +from assistant.logging import logger +from assistant.notifications import Notifications +from assistant.prompt_utils import ( + ContextSection, + ContextStrategy, + Instructions, + Prompt, + TokenBudget, + add_context_to_prompt, +) +from assistant.tools import ShareTools +from assistant.utils import load_text_include + + +class ActorOutput(BaseModel): + """ + Attributes: + response: The response from the assistant. + """ + + accomplishments: str = Field( + description="A summary of all the actions performed and their results.", + ) + user_information_requests: list[NewInformationRequest] = Field( + description="A list of all the information needed from the user to resolve tasks.", + ) + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +async def act( + context: ConversationContext, + attachments_extension: AttachmentsExtension, + metadata: dict[str, Any], +) -> ActorOutput | None: + """ + Work, work, work, work, work... + """ + + if "debug" not in metadata: + metadata["debug"] = {} + debug = metadata["debug"] + + config = await assistant_config.get(context.assistant) + model = config.request_config.openai_model + role = await ShareManager.get_conversation_role(context) + debug["role"] = role + token_budget = TokenBudget(config.request_config.max_tokens) + + instructions = load_text_include("actor_instructions.md") + instructions = Instructions(instructions) + prompt = Prompt( + instructions=instructions, + context_strategy=ContextStrategy.MULTI, + ) + sections = [ + ContextSection.KNOWLEDGE_INFO, + ContextSection.KNOWLEDGE_BRIEF, + ContextSection.TARGET_AUDIENCE, + # ContextSection.LEARNING_OBJECTIVES, + ContextSection.KNOWLEDGE_DIGEST, + ContextSection.INFORMATION_REQUESTS, + # ContextSection.SUGGESTED_NEXT_ACTIONS, + ContextSection.ATTACHMENTS, + ContextSection.TASKS, + ContextSection.COORDINATOR_CONVERSATION, + ] + await add_context_to_prompt( + prompt, + context=context, + role=role, + model=model, + token_limit=config.request_config.max_tokens, + attachments_extension=attachments_extension, + attachments_config=config.attachments_config, + attachments_in_system_message=False, + include=sections, + ) + + # Calculate token count for all prompt so far. + completion_messages = prompt.messages() + token_budget.add( + num_tokens_from_messages( + model=model, + messages=completion_messages, + ) + ) + + content = "" + async with openai_client.create_client(config.service_config) as client: + try: + completion_args = { + "messages": completion_messages, + "model": model, + "max_tokens": config.request_config.response_tokens, + "temperature": 0.7, + "response_format": ActorOutput, + } + debug["completion_args"] = openai_client.serializable(completion_args) + + response, _ = await complete_with_tool_calls( + async_client=client, + completion_args=completion_args, + tool_functions=ShareTools(context).act_tools(), + metadata=debug, + max_tool_call_rounds=32, + ) + + if response and response.choices and response.choices[0].message.parsed: + output: ActorOutput | None = response.choices[0].message.parsed + debug["completion_response"] = openai_client.serializable(response.model_dump()) + + if output and output.accomplishments: + for req in output.user_information_requests: + await InformationRequestManager.create_information_request( + context=context, + title=req.title, + description=req.description, + priority=req.priority, + source=InformationRequestSource.INTERNAL, + ) + # if output.accomplishments: + await context.send_messages( + NewConversationMessage( + content=output.accomplishments, + message_type=MessageType.notice, + metadata=metadata, + ) + ) + await Notifications.notify_state_update( + context, + [InspectorTab.DEBUG], + ) + + return output + + except CompletionError as e: + logger.exception(f"Exception occurred calling OpenAI chat completion: {e}") + debug["error"] = str(e) + if isinstance(e.body, dict) and "message" in e.body: + content = e.body.get("message", e.message) + elif e.message: + content = e.message + else: + content = "An error occurred while processing your request." + await context.send_messages( + NewConversationMessage( + content=content, + message_type=MessageType.notice, + metadata=metadata, + ) + ) + return + + +def get_formatted_token_count(tokens: int) -> str: + # if less than 1k, return the number of tokens + # if greater than or equal to 1k, return the number of tokens in k + # use 1 decimal place for k + # drop the decimal place if the number of tokens in k is a whole number + if tokens < 1000: + return str(tokens) + else: + tokens_in_k = tokens / 1000 + if tokens_in_k.is_integer(): + return f"{int(tokens_in_k)}k" + else: + return f"{tokens_in_k:.1f}k" + + +def get_token_usage_message( + max_tokens: int, + total_tokens: int, + request_tokens: int, + completion_tokens: int, +) -> str: + """ + Generate a display friendly message for the token usage, to be added to the footer items. + """ + + return dedent(f""" + Tokens used: {get_formatted_token_count(total_tokens)} + ({get_formatted_token_count(request_tokens)} in / {get_formatted_token_count(completion_tokens)} out) + of {get_formatted_token_count(max_tokens)} ({int(total_tokens / max_tokens * 100)}%) + """).strip() + + +def get_response_duration_message(response_duration: float) -> str: + """ + Generate a display friendly message for the response duration, to be added to the footer items. + """ + + return f"Response time: {response_duration:.2f} seconds" diff --git a/assistants/project-assistant/assistant/agentic/coordinator_next_action.py b/assistants/project-assistant/assistant/agentic/coordinator_next_action.py new file mode 100644 index 000000000..d70881559 --- /dev/null +++ b/assistants/project-assistant/assistant/agentic/coordinator_next_action.py @@ -0,0 +1,115 @@ +""" +Coordinator support and helper functions for Knowledge Transfer Assistant. + +Provides next action suggestions and other coordinator utilities. +""" + +from semantic_workbench_assistant.assistant_app import ConversationContext + +from assistant.data import RequestStatus +from assistant.domain import ShareManager, TransferManager +from assistant.logging import logger + + +async def get_coordinator_next_action_suggestion( + context: ConversationContext, +) -> str | None: + """ + Generate next action suggestions for the coordinator based on the knowledge transfer state. + + This output is passed to the assistant and helps guide the conversation toward completing or improving + the knowledge share in a helpful, structured way. + + Returns: + A user-facing suggestion string, or None if no suggestion is needed. + """ + try: + share_id = await ShareManager.get_share_id(context) + if not share_id: + logger.warning("No share ID found for this conversation") + return None + + share = await ShareManager.get_share(context) + if not share: + return None + + brief = share.brief + requests = share.requests + active_requests = [r for r in requests if r.status == RequestStatus.NEW] + + # 1. Unresolved requests come first + if active_requests: + request = active_requests[0] + return ( + f"There are {len(active_requests)} unanswered questions from team members. " + f'One of them is: "{request.title}" Let\'s work on answering it.' + ) + + # 2. Audience not yet defined + if not share.audience: + return ( + "Let's start by defining who your audience is. Who is this knowledge for, and what's their background?" + ) + + # 3. Knowledge not yet organized + # if not share.knowledge_organized: + # return ( + # "Next, let's organize your knowledge. Upload any relevant files or describe the knowledge " + # "you want to transfer. When you're ready, I can mark the knowledge as organized." + # ) + + # 4. Brief not yet written + if not brief: + return ( + "Your knowledge share-out needs a short introduction that will orient your team. " + "Let's write a knowledge brief next. The knowledge brief helps your team understand " + "the purpose of this knowledge transfer and will be visible to all team members in their side panel." + ) + + # 5. If intended to have outcomes but none defined yet + if share.is_intended_to_accomplish_outcomes and not share.learning_objectives: + return ( + "Would you like your team to achieve any specific outcomes? If so, let's define some learning objectives. " # noqa: E501 + "If not, you can mark this share-out as 'exploratory' instead." + ) + + # 6. Objectives exist, but missing outcomes + if share.is_intended_to_accomplish_outcomes: + incomplete_objectives = [obj for obj in share.learning_objectives if not obj.learning_outcomes] + if incomplete_objectives: + name = incomplete_objectives[0].name + return ( + f"The learning objective '{name}' doesn't have any outcomes yet. " + f"Let's define what your team should accomplish to meet it." + ) + + # 7. Ready for transfer but not yet shared + if TransferManager.is_ready_for_transfer(share) and not TransferManager.is_actively_sharing(share): + return ( + "Your knowledge is ready to share. Would you like to create a message and generate the invitation link?" + ) + + # 8. Actively sharing - monitor and support ongoing transfer + if TransferManager.is_actively_sharing(share): + if share.is_intended_to_accomplish_outcomes and not TransferManager._is_transfer_complete(share): + team_count = len(share.team_conversations) + return ( + f"Great! Your knowledge is being shared with {team_count} team member" + f"{'s' if team_count != 1 else ''}. You can continue improving the knowledge share or " + f"respond to information requests as they come in." + ) + else: + return ( + "Your knowledge transfer is in progress. You can continue improving the knowledge share or " + "respond to information requests as they come in." + ) + + # 9. Default: General support + return ( + "Your knowledge share is available. You can continue improving it or " + "respond to new information requests as they come in." + ) + + except Exception as e: + logger.exception(f"Error generating next action suggestion: {e}") + return None diff --git a/assistants/project-assistant/assistant/agentic/create_invitation.py b/assistants/project-assistant/assistant/agentic/create_invitation.py new file mode 100644 index 000000000..9f8390b79 --- /dev/null +++ b/assistants/project-assistant/assistant/agentic/create_invitation.py @@ -0,0 +1,91 @@ +from typing import Any + +import openai_client +from semantic_workbench_assistant.assistant_app import ConversationContext + +from assistant.config import assistant_config +from assistant.domain.share_manager import ShareManager +from assistant.logging import convert_to_serializable, logger +from assistant.notifications import Notifications +from assistant.prompt_utils import ( + ContextSection, + ContextStrategy, + Instructions, + Prompt, + add_context_to_prompt, +) +from assistant.utils import load_text_include + + +async def create_invitation(context: ConversationContext) -> str: + debug: dict[str, Any] = { + "context": convert_to_serializable(context.to_dict()), + } + + config = await assistant_config.get(context.assistant) + + # Set up prompt instructions. + instruction_text = load_text_include("create_invitation.md") + instructions = Instructions(instruction_text) + prompt = Prompt( + instructions=instructions, + context_strategy=ContextStrategy.MULTI, + ) + + # Add prompt context. + role = await ShareManager.get_conversation_role(context) + await add_context_to_prompt( + prompt, + context=context, + role=role, + model=config.request_config.openai_model, + token_limit=config.request_config.max_tokens, + attachments_config=config.attachments_config, + attachments_in_system_message=True, + include=[ + ContextSection.KNOWLEDGE_INFO, + ContextSection.KNOWLEDGE_BRIEF, + # ContextSection.TASKS, + ContextSection.TARGET_AUDIENCE, + # ContextSection.LEARNING_OBJECTIVES, + ContextSection.KNOWLEDGE_DIGEST, + # ContextSection.INFORMATION_REQUESTS, + # ContextSection.SUGGESTED_NEXT_ACTIONS, + ContextSection.COORDINATOR_CONVERSATION, + ContextSection.ATTACHMENTS, + ], + ) + + # Chat completion + async with openai_client.create_client(config.service_config) as client: + try: + completion_args = { + "messages": prompt.messages(), + "model": config.request_config.openai_model, + "max_tokens": 500, + "temperature": 0.8, + } + debug["completion_args"] = openai_client.serializable(completion_args) + + # LLM call + response = await client.chat.completions.create( + **completion_args, + ) + openai_client.validate_completion(response) + debug["completion_response"] = openai_client.serializable(response.model_dump()) + + # Response + if response and response.choices and response.choices[0].message.content: + output: str = response.choices[0].message.content + if output: + await Notifications.notify(context, f"Generated invitation.\n\n{output}", debug_data=debug) + return output + else: + logger.warning("Empty response from LLM while generating invitation.") + + except Exception as e: + logger.exception(f"Failed to make OpenIA call: {e}") + debug["error"] = str(e) + + # logger.debug(f"{__name__}: {debug}") + return "Failed to generate invitation." diff --git a/assistants/project-assistant/assistant/agentic/detect_audience_and_takeaways.py b/assistants/project-assistant/assistant/agentic/detect_audience_and_takeaways.py new file mode 100644 index 000000000..576278368 --- /dev/null +++ b/assistants/project-assistant/assistant/agentic/detect_audience_and_takeaways.py @@ -0,0 +1,113 @@ +from typing import Any + +import openai_client +from assistant_extensions.attachments import AttachmentsExtension +from pydantic import BaseModel +from semantic_workbench_assistant.assistant_app import ConversationContext + +from assistant.config import assistant_config +from assistant.data import InspectorTab, NewTaskInfo +from assistant.domain.share_manager import ShareManager +from assistant.domain.tasks_manager import TasksManager +from assistant.logging import logger +from assistant.notifications import Notifications +from assistant.prompt_utils import ( + ContextSection, + ContextStrategy, + Instructions, + Prompt, + add_context_to_prompt, +) +from assistant.utils import load_text_include + + +async def detect_audience_and_takeaways( + context: ConversationContext, attachments_extension: AttachmentsExtension +) -> None: + debug: dict[str, Any] = { + "context": context.to_dict(), + } + + config = await assistant_config.get(context.assistant) + + # Set up prompt instructions. + instruction_text = load_text_include("detect_audience.md") + instructions = Instructions(instruction_text) + prompt = Prompt( + instructions=instructions, + context_strategy=ContextStrategy.MULTI, + ) + + # Add prompt context. + role = await ShareManager.get_conversation_role(context) + await add_context_to_prompt( + prompt, + context=context, + role=role, + model=config.request_config.openai_model, + token_limit=config.request_config.max_tokens, + attachments_extension=attachments_extension, + attachments_config=config.attachments_config, + attachments_in_system_message=True, + include=[ + # ContextSection.KNOWLEDGE_INFO, + # ContextSection.KNOWLEDGE_BRIEF, + ContextSection.TARGET_AUDIENCE, + ContextSection.LEARNING_OBJECTIVES, + # ContextSection.KNOWLEDGE_DIGEST, + # ContextSection.INFORMATION_REQUESTS, + # ContextSection.SUGGESTED_NEXT_ACTIONS, + ContextSection.COORDINATOR_CONVERSATION, + ContextSection.ATTACHMENTS, + ContextSection.TASKS, + ], + ) + + class Output(BaseModel): + """Output class to hold the generated tasks.""" + + tasks: list[ + NewTaskInfo + ] # Tasks related to the audience and takeaways. One task per item. If there are no tasks to be added, this will be an empty list. #noqa: E501 + + # Chat completion + async with openai_client.create_client(config.service_config) as client: + try: + completion_args = { + "messages": prompt.messages(), + "model": config.request_config.openai_model, + "max_tokens": 500, + "temperature": 0.8, + "response_format": Output, + } + debug["completion_args"] = openai_client.serializable(completion_args) + + # LLM call + response = await client.beta.chat.completions.parse( + **completion_args, + ) + openai_client.validate_completion(response) + debug["completion_response"] = openai_client.serializable(response.model_dump()) + + # Response + if response and response.choices and response.choices[0].message.parsed: + output: Output = response.choices[0].message.parsed + if output.tasks: + await TasksManager.add_tasks(context, output.tasks) + await Notifications.notify( + context, + f"Added {len(output.tasks)} tasks related to the audience and takeaways.", + debug_data=debug, + ) + await Notifications.notify_state_update( + context, + [InspectorTab.DEBUG], + ) + else: + logger.warning("Empty response from LLM for welcome message generation") + + except Exception as e: + logger.exception(f"Failed to make OpenIA call: {e}") + debug["error"] = str(e) + + # logger.debug(f"{__name__}: {debug}") diff --git a/assistants/project-assistant/assistant/agentic/detect_coordinator_actions.py b/assistants/project-assistant/assistant/agentic/detect_coordinator_actions.py new file mode 100644 index 000000000..a57b81f83 --- /dev/null +++ b/assistants/project-assistant/assistant/agentic/detect_coordinator_actions.py @@ -0,0 +1,110 @@ +from typing import Any + +import openai_client +from assistant_extensions.attachments import AttachmentsExtension +from pydantic import BaseModel +from semantic_workbench_assistant.assistant_app import ConversationContext + +from assistant.config import assistant_config +from assistant.data import InspectorTab, NewTaskInfo +from assistant.domain.share_manager import ShareManager +from assistant.domain.tasks_manager import TasksManager +from assistant.logging import logger +from assistant.notifications import Notifications +from assistant.prompt_utils import ( + ContextSection, + ContextStrategy, + Instructions, + Prompt, + add_context_to_prompt, +) +from assistant.utils import load_text_include + + +async def detect_coordinator_actions(context: ConversationContext, attachments_extension: AttachmentsExtension) -> None: + debug: dict[str, Any] = { + "context": context.to_dict(), + } + + config = await assistant_config.get(context.assistant) + + # Set up prompt instructions. + instruction_text = load_text_include("detect_coordinator_actions.md") + instructions = Instructions(instruction_text) + prompt = Prompt( + instructions=instructions, + context_strategy=ContextStrategy.MULTI, + ) + + # Add prompt context. + role = await ShareManager.get_conversation_role(context) + await add_context_to_prompt( + prompt, + context=context, + role=role, + model=config.request_config.openai_model, + token_limit=config.request_config.max_tokens, + attachments_extension=attachments_extension, + attachments_config=config.attachments_config, + attachments_in_system_message=True, + include=[ + ContextSection.KNOWLEDGE_INFO, + ContextSection.KNOWLEDGE_BRIEF, + ContextSection.TASKS, + ContextSection.TARGET_AUDIENCE, + ContextSection.LEARNING_OBJECTIVES, + ContextSection.KNOWLEDGE_DIGEST, + ContextSection.INFORMATION_REQUESTS, + # ContextSection.SUGGESTED_NEXT_ACTIONS, + ContextSection.COORDINATOR_CONVERSATION, + ContextSection.ATTACHMENTS, + ], + ) + + class Output(BaseModel): + """Output class to hold the additional tasks.""" + + tasks: list[ + str + ] # Additional tasks that should be completed. If there are no additional tasks needed, this will be an empty list. #noqa: E501 + + # Chat completion + async with openai_client.create_client(config.service_config) as client: + try: + completion_args = { + "messages": prompt.messages(), + "model": config.request_config.openai_model, + "max_tokens": 500, + "temperature": 0.7, + "response_format": Output, + } + debug["completion_args"] = openai_client.serializable(completion_args) + + # LLM call + response = await client.beta.chat.completions.parse( + **completion_args, + ) + openai_client.validate_completion(response) + debug["completion_response"] = openai_client.serializable(response.model_dump()) + + # Response + if response and response.choices and response.choices[0].message.parsed: + output: Output = response.choices[0].message.parsed + if output.tasks: + new_tasks = [NewTaskInfo(content=task) for task in output.tasks] + await TasksManager.add_tasks(context, new_tasks) + await Notifications.notify( + context, f"Added {len(output.tasks)} tasks related to the process.", debug_data=debug + ) + await Notifications.notify_state_update( + context, + [InspectorTab.DEBUG], + ) + else: + logger.warning("Empty response from LLM for welcome message generation") + + except Exception as e: + logger.exception(f"Failed to make OpenIA call: {e}") + debug["error"] = str(e) + + # logger.debug(f"{__name__}: {debug}") diff --git a/assistants/project-assistant/assistant/project_analysis.py b/assistants/project-assistant/assistant/agentic/detect_information_request_needs.py similarity index 62% rename from assistants/project-assistant/assistant/project_analysis.py rename to assistants/project-assistant/assistant/agentic/detect_information_request_needs.py index 7b06474b2..4b47bf7b9 100644 --- a/assistants/project-assistant/assistant/project_analysis.py +++ b/assistants/project-assistant/assistant/agentic/detect_information_request_needs.py @@ -1,22 +1,22 @@ """ -Analysis and detection functions for the project assistant. +Analysis and detection functions for the knowledge transfer assistant. -This module contains functions for analyzing messages and project content +This module contains functions for analyzing messages and knowledge transfer content to detect specific conditions, such as information request needs. """ import json -from typing import Any, Dict, List +from typing import Any import openai_client from openai.types.chat import ChatCompletionMessageParam from semantic_workbench_assistant.assistant_app import ConversationContext -from .config import assistant_config -from .logging import logger +from assistant.config import assistant_config +from assistant.logging import convert_to_serializable, logger -async def detect_information_request_needs(context: ConversationContext, message: str) -> Dict[str, Any]: +async def detect_information_request_needs(context: ConversationContext, message: str) -> dict[str, Any]: """ Analyze a user message in context of recent chat history to detect potential information request needs. Uses an LLM for sophisticated detection. @@ -28,74 +28,46 @@ async def detect_information_request_needs(context: ConversationContext, message Returns: Dict with detection results including is_information_request, confidence, and other metadata """ - debug: Dict[str, Any] = { + debug: dict[str, Any] = { "message": message, - "context": context, + "context": convert_to_serializable(context.to_dict()), } - # Get config via assistant config config = await assistant_config.get(context.assistant) - # Check if we're in a test environment (Missing parts of context) - if not hasattr(context, "assistant") or context.assistant is None: - return { - "is_information_request": False, - "reason": "Unable to perform detection in test environment - missing context", - "confidence": 0.0, - "debug": debug, - } - - # Get the config - config = await assistant_config.get(context.assistant) - - # Verify service_config is available - if not config.service_config: - logger.warning("No service_config available for LLM-based detection") - return { - "is_information_request": False, - "reason": "LLM detection unavailable - missing service configuration", - "confidence": 0.0, - "debug": debug, - } - - # Get recent conversation history (up to 10 messages) + # Get chat history chat_history = [] try: - # Get recent messages to provide context messages_response = await context.get_messages(limit=10) if messages_response and messages_response.messages: - # Format messages for the LLM for msg in messages_response.messages: - # Format the sender name sender_name = "Team Member" if msg.sender.participant_id == context.assistant.id: sender_name = "Assistant" - - # Add to chat history role = "user" if sender_name == "Team Member" else "assistant" chat_history.append({"role": role, "content": f"{sender_name}: {msg.content}"}) - - # Reverse to get chronological order chat_history.reverse() except Exception as e: logger.warning(f"Could not retrieve chat history: {e}") - # Continue without history if we can't get it try: - # Create chat completion with history context async with openai_client.create_client(config.service_config) as client: - # Prepare messages array with system prompt and chat history - messages: List[ChatCompletionMessageParam] = [ - {"role": "system", "content": config.prompt_config.project_information_request_detection} + messages: list[ChatCompletionMessageParam] = [ + { + "role": "system", + "content": config.prompt_config.detect_information_request_needs, + } ] - # Add chat history if available if chat_history: for history_msg in chat_history: messages.append({"role": history_msg["role"], "content": history_msg["content"]}) # Add the current message for analysis - explicitly mark as the latest message - messages.append({"role": "user", "content": f"Latest message from Team Member: {message}"}) + messages.append({ + "role": "user", + "content": f"Latest message from Team Member: {message}", + }) completion_args = { "model": "gpt-3.5-turbo", @@ -104,9 +76,8 @@ async def detect_information_request_needs(context: ConversationContext, message "max_tokens": 500, "temperature": 0.2, # Low temperature for more consistent analysis } - debug["completion_args"] = openai_client.make_completion_args_serializable(completion_args) + debug["completion_args"] = openai_client.serializable(completion_args) - # Make the API call response = await client.chat.completions.create( **completion_args, ) @@ -125,6 +96,7 @@ async def detect_information_request_needs(context: ConversationContext, message "is_information_request": False, "reason": "Failed to parse LLM response", "confidence": 0.0, + "debug": debug, } else: logger.warning("Empty response from LLM for information request detection") @@ -139,7 +111,7 @@ async def detect_information_request_needs(context: ConversationContext, message debug["error"] = str(e) return { "is_information_request": False, - "reason": f"LLM detection error: {str(e)}", + "reason": f"LLM detection error: {e!s}", "confidence": 0.0, "debug": debug, } diff --git a/assistants/project-assistant/assistant/agentic/detect_knowledge_package_gaps.py b/assistants/project-assistant/assistant/agentic/detect_knowledge_package_gaps.py new file mode 100644 index 000000000..0cf0c071e --- /dev/null +++ b/assistants/project-assistant/assistant/agentic/detect_knowledge_package_gaps.py @@ -0,0 +1,132 @@ +from typing import Any + +import openai_client +from assistant_extensions.attachments import AttachmentsExtension +from pydantic import BaseModel +from semantic_workbench_assistant.assistant_app import ConversationContext + +from assistant.config import assistant_config +from assistant.data import InspectorTab, NewTaskInfo, TaskStatus +from assistant.domain.share_manager import ShareManager +from assistant.domain.tasks_manager import TasksManager +from assistant.logging import convert_to_serializable, logger +from assistant.notifications import Notifications +from assistant.prompt_utils import ( + ContextSection, + ContextStrategy, + Instructions, + Prompt, + add_context_to_prompt, +) +from assistant.utils import load_text_include + + +async def detect_knowledge_package_gaps( + context: ConversationContext, attachments_extension: AttachmentsExtension +) -> None: + debug: dict[str, Any] = { + "context": convert_to_serializable(context.to_dict()), + } + + config = await assistant_config.get(context.assistant) + + share = await ShareManager.get_share(context) + if ( + share.digest is None + and share.tasks + and not any(task.content == "The user must define the knowledge package content." for task in share.tasks) + ): + task = NewTaskInfo( + content="The user must define the knowledge package content.", + status=TaskStatus.PENDING, + ) + await TasksManager.add_tasks(context, [task]) + await Notifications.notify_state_update( + context, + [InspectorTab.DEBUG], + ) + return + + # Set up prompt instructions. + instruction_text = load_text_include("detect_knowledge_package_gaps.md") + instructions = Instructions(instruction_text) + prompt = Prompt( + instructions=instructions, + context_strategy=ContextStrategy.MULTI, + ) + + # Add prompt context. + role = await ShareManager.get_conversation_role(context) + await add_context_to_prompt( + prompt, + context=context, + role=role, + model=config.request_config.openai_model, + token_limit=config.request_config.max_tokens, + attachments_extension=attachments_extension, + attachments_config=config.attachments_config, + attachments_in_system_message=True, + include=[ + # ContextSection.KNOWLEDGE_INFO, + ContextSection.KNOWLEDGE_BRIEF, + ContextSection.TASKS, + ContextSection.TARGET_AUDIENCE, + # ContextSection.LEARNING_OBJECTIVES, + ContextSection.KNOWLEDGE_DIGEST, + ContextSection.INFORMATION_REQUESTS, + # ContextSection.SUGGESTED_NEXT_ACTIONS, + ContextSection.COORDINATOR_CONVERSATION, + ContextSection.ATTACHMENTS, + ], + ) + + class Output(BaseModel): + """Identified knowledge gaps.""" + + gaps: list[str] # Gaps in the knowledge package that need to be addressed. + + # Chat completion + async with openai_client.create_client(config.service_config) as client: + try: + completion_args = { + "messages": prompt.messages(), + "model": config.request_config.openai_model, + "max_tokens": 500, + "temperature": 0.8, + "response_format": Output, + } + debug["completion_args"] = openai_client.serializable(completion_args) + + # LLM call + response = await client.beta.chat.completions.parse( + **completion_args, + ) + openai_client.validate_completion(response) + debug["completion_response"] = openai_client.serializable(response.model_dump()) + + # Response + if response and response.choices and response.choices[0].message.parsed: + output: Output = response.choices[0].message.parsed + if output.gaps: + task_contents = [f"Ask the user about: {gap.strip()}" for gap in output.gaps if gap.strip()] + tasks = [NewTaskInfo(content=content) for content in task_contents] + await TasksManager.add_tasks(context, tasks) + await Notifications.notify( + context, f"Added {len(tasks)} tasks related to the knowledge content.", debug_data=debug + ) + await Notifications.notify_state_update( + context, + [InspectorTab.DEBUG], + ) + else: + await Notifications.notify( + context, "No knowledge gaps identified. All required information is present.", debug_data=debug + ) + else: + logger.warning("Empty response from LLM for welcome message generation") + + except Exception as e: + logger.exception(f"Failed to make OpenIA call: {e}") + debug["error"] = str(e) + + # logger.debug(f"{__name__}: {debug}") diff --git a/assistants/project-assistant/assistant/agentic/focus.py b/assistants/project-assistant/assistant/agentic/focus.py new file mode 100644 index 000000000..84a7d6f29 --- /dev/null +++ b/assistants/project-assistant/assistant/agentic/focus.py @@ -0,0 +1,107 @@ +from typing import Any + +import openai_client +from assistant_extensions.attachments import AttachmentsExtension +from pydantic import BaseModel +from semantic_workbench_assistant.assistant_app import ConversationContext + +from assistant.config import assistant_config +import uuid +from assistant.data import InspectorTab, TaskInfo, TaskPriority, TaskStatus +from assistant.domain.tasks_manager import TasksManager +from assistant.logging import logger +from assistant.notifications import Notifications +from assistant.prompt_utils import ( + ContextStrategy, + DataContext, + Instructions, + Prompt, +) +from assistant.utils import load_text_include + + +async def focus(context: ConversationContext, attachments_extension: AttachmentsExtension) -> None: + debug: dict[str, Any] = { + "context": context.to_dict(), + } + + config = await assistant_config.get(context.assistant) + + # Set up prompt instructions. + instruction_text = load_text_include("focus.md") + instructions = Instructions(instruction_text) + prompt = Prompt( + instructions=instructions, + context_strategy=ContextStrategy.MULTI, + ) + + tasks = await TasksManager.get_tasks(context) + if tasks: + tasks_data = "\n\n".join("- " + task.content for task in tasks) + prompt.contexts.append( + DataContext( + "Consulting Tasks", + tasks_data, + "The consultant's current task list for the knowledge transfer consulting project.", + ) + ) + else: + prompt.contexts.append( + DataContext( + "Consulting Tasks", + "[]", + "The consultant has no current tasks for the knowledge transfer consulting project.", + ) + ) + + class Output(BaseModel): + """Output class to hold the generated tasks.""" + + reasoning: str # Reasoning behind how you are focusing the task list. + focused_tasks: list[str] # Focused task list for the knowledge transfer consultant. + + # Chat completion + async with openai_client.create_client(config.service_config) as client: + try: + completion_args = { + "messages": prompt.messages(), + "model": config.request_config.openai_model, + "max_tokens": 500, + "temperature": 0.8, + "response_format": Output, + } + debug["completion_args"] = openai_client.serializable(completion_args) + + # LLM call + response = await client.beta.chat.completions.parse( + **completion_args, + ) + openai_client.validate_completion(response) + debug["completion_response"] = openai_client.serializable(response.model_dump()) + + # Response + if response and response.choices and response.choices[0].message.parsed: + output: Output = response.choices[0].message.parsed + if output.focused_tasks: + focused_task_infos = [ + TaskInfo( + task_id=str(uuid.uuid4()), + content=task, + status=TaskStatus.PENDING, + priority=TaskPriority.MEDIUM + ) for task in output.focused_tasks + ] + await TasksManager.set_task_list(context, focused_task_infos) + await Notifications.notify(context, "Focused the task list.", debug_data=debug) + await Notifications.notify_state_update( + context, + [InspectorTab.DEBUG], + ) + else: + logger.warning("Empty response from LLM for welcome message generation") + + except Exception as e: + logger.exception(f"Failed to make OpenIA call: {e}") + debug["error"] = str(e) + + # logger.debug(f"{__name__}: {debug}") diff --git a/assistants/project-assistant/assistant/agentic/respond.py b/assistants/project-assistant/assistant/agentic/respond.py new file mode 100644 index 000000000..0e5a81bc5 --- /dev/null +++ b/assistants/project-assistant/assistant/agentic/respond.py @@ -0,0 +1,435 @@ +import time +from textwrap import dedent +from typing import Any, ClassVar + +import openai_client +from assistant_extensions.attachments import AttachmentsExtension +from openai import BaseModel +from openai.types.chat import ( + ChatCompletionAssistantMessageParam, + ChatCompletionMessageParam, + ChatCompletionSystemMessageParam, + ChatCompletionUserMessageParam, +) +from openai_client import num_tokens_from_messages +from openai_client.completion import assistant_message_from_completion +from openai_client.errors import CompletionError +from openai_client.tools import complete_with_tool_calls +from pydantic import ConfigDict, Field +from semantic_workbench_api_model.workbench_model import ( + ConversationMessage, + ConversationParticipantList, + MessageType, + NewConversationMessage, +) +from semantic_workbench_assistant.assistant_app import ( + ConversationContext, +) + +from assistant.config import assistant_config +from assistant.data import ConversationRole +from assistant.domain.conversation_preferences_manager import ( + ConversationPreferencesManager, +) +from assistant.domain.share_manager import ShareManager +from assistant.logging import logger +from assistant.prompt_utils import ( + ContextSection, + ContextStrategy, + DataContext, + Instructions, + Prompt, + TokenBudget, + add_context_to_prompt, +) +from assistant.tools import ShareTools +from assistant.utils import load_text_include + +SILENCE_TOKEN = "{{SILENCE}}" + + +def format_message(participants: ConversationParticipantList, message: ConversationMessage) -> str: + """Consistent formatter that includes the participant name for multi-participant and name references""" + conversation_participant = next( + (participant for participant in participants.participants if participant.id == message.sender.participant_id), + None, + ) + participant_name = conversation_participant.name if conversation_participant else "unknown" + message_datetime = message.timestamp.strftime("%Y-%m-%d %H:%M:%S") + return f"[{participant_name} - {message_datetime}]: {message.content}" + + +class CoordinatorOutput(BaseModel): + """ + Attributes: + response: The response from the assistant. + """ + + response: str = Field( + description="The response from the assistant. The response should not duplicate information from the excerpt but may refer to it.", # noqa: E501 + ) + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class TeamOutput(BaseModel): + """ + Attributes: + citations: A list of citations from which the response is generated. There should always be at least one citation, but it can be empty if the assistant has no relevant information to cite. + excerpt: A verbatim excerpt from one of the cited works that illustrates why this response was given. It should have enough context to get a good idea of what's in that part of the cited work. DO NOT excerpt from CONVERSATION or DIGEST, only from attachments. If there is no relevant excerpt, this will be None. If there is special formatting in the excerpt, remove it as the excerpt will be displayed in quotes in a chat message and should not contain any formatting that would not be supported in a chat message (e.g. markdown). + next_step_suggestion: Suggest more areas to explore using content from the knowledge digest to ensure your conversation covers all of the relevant information. + """ # noqa: E501 + + citations: list[str] = Field( + description="A list of citations from which the response is generated. There should always be at least one citation, but it can be empty if the assistant has no relevant information to cite.", # noqa: E501 + ) + excerpt: str | None = Field( + description="A verbatim excerpt from one of the cited works that illustrates why this response was given. It should have enough context to get a good idea of what's in that part of the cited work. DO NOT excerpt from CONVERSATION or KNOWLEDGE_DIGEST, only from attachments. If there is no relevant excerpt, this will be None. If there is special formatting in the excerpt, remove it as the excerpt will be displayed in quotes in a chat message and should not contain any formatting that would not be supported in a chat message (e.g. markdown).", # noqa: E501 + ) + response: str = Field( + description="The response from the assistant. The response should not duplicate information from the excerpt but may refer to it.", # noqa: E501 + ) + next_step_suggestion: str = Field( + description="Suggest more areas to explore using content from the knowledge digest to ensure your conversation covers all of the relevant information. For example: 'Would you like to explore ... next?'.", # noqa: E501 + ) + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +async def respond_to_conversation( + context: ConversationContext, + new_message: ConversationMessage, + attachments_extension: AttachmentsExtension, + metadata: dict[str, Any], + user_information_requests: list[str] | None = None, +) -> ChatCompletionAssistantMessageParam | None: + """ + Respond to a conversation message. + """ + if "debug" not in metadata: + metadata["debug"] = {} + + config = await assistant_config.get(context.assistant) + model = config.request_config.openai_model + role = await ShareManager.get_conversation_role(context) + metadata["debug"]["role"] = role + token_budget = TokenBudget(config.request_config.max_tokens) + + ## + ## INSTRUCTIONS + ## + + # Add role-specific instructions. + if role == ConversationRole.COORDINATOR: + role_specific_instructions = config.prompt_config.coordinator_instructions + else: + role_specific_instructions = config.prompt_config.team_instructions + instructions = Instructions(role_specific_instructions) + + # Add knowledge digest instructions. + instructions.add_subsection( + Instructions( + load_text_include("knowledge_digest_instructions.txt"), + "Assistant's Knowledge Digest", + ) + ) + + # If this is a multi-participant conversation, add a note about the participants. + participants = await context.get_participants(include_inactive=True) + if len(participants.participants) > 2: + participant_text = ( + "\n\n" + f"There are {len(participants.participants)} participants in the conversation," + " including you as the assistant and the following users:" + + ",".join([ + f' "{participant.name}"' + for participant in participants.participants + if participant.id != context.assistant.id + ]) + + "\n\nYou do not need to respond to every message. Do not respond if the last thing said was a closing" + " statement such as 'bye' or 'goodbye', or just a general acknowledgement like 'ok' or 'thanks'. Do not" + f' respond as another user in the conversation, only as "{context.assistant.name}".' + " Sometimes the other users need to talk amongst themselves and that is ok. If the conversation seems to" + f' be directed at you or the general audience, go ahead and respond.\n\nSay "{SILENCE_TOKEN}" to skip' + " your turn." + ) + instructions.add_subsection(Instructions(participant_text, "Multi-participant conversation instructions")) + + # Add conversation preferences instructions. + communication_style = await ConversationPreferencesManager.get_preferred_communication_style(context) + instructions.add_subsection(Instructions(communication_style, "Preferred Communication Style")) + + prompt = Prompt( + instructions=instructions, + context_strategy=ContextStrategy.MULTI, + ) + if role == ConversationRole.TEAM: + prompt.output_format = "Respond as JSON with your response in the `response` field and all citations in the `citations` field. In the `next_step_suggestion` field, suggest more areas to explore using content from the assistant whiteboard to ensure your conversation covers all of the relevant information." # noqa: E501 + + ## + ## CONTEXT + ## + + sections = [ + ContextSection.KNOWLEDGE_INFO, + ContextSection.KNOWLEDGE_BRIEF, + ContextSection.TARGET_AUDIENCE, + # ContextSection.LEARNING_OBJECTIVES, + ContextSection.KNOWLEDGE_DIGEST, + ContextSection.INFORMATION_REQUESTS, + # ContextSection.SUGGESTED_NEXT_ACTIONS, + ContextSection.ATTACHMENTS, + ContextSection.TASKS, + ] + if role == ConversationRole.TEAM: + sections.append(ContextSection.COORDINATOR_CONVERSATION) + + await add_context_to_prompt( + prompt, + context=context, + role=role, + model=model, + token_limit=config.request_config.max_tokens, + attachments_extension=attachments_extension, + attachments_config=config.attachments_config, + attachments_in_system_message=False, + include=sections, + ) + + user_information_requests_data = "- ".join(user_information_requests) if user_information_requests else "None" + prompt.contexts.append( + DataContext( + "Information Needed from the User", + user_information_requests_data, + ) + ) + + # Calculate token count for all prompt so far. + completion_messages = prompt.messages() + token_budget.add( + num_tokens_from_messages( + model=model, + messages=completion_messages, + ) + ) + + ### + ### USER MESSAGE + ### + + if new_message.sender.participant_id == context.assistant.id: + user_message: ChatCompletionMessageParam = ChatCompletionAssistantMessageParam( + role="assistant", + content=format_message(participants, new_message), + ) + else: + user_message: ChatCompletionMessageParam = ChatCompletionUserMessageParam( + role="user", + content=format_message(participants, new_message), + ) + + token_budget.add( + num_tokens_from_messages( + model=model, + messages=[user_message], + ) + ) + + ### + ### HISTORY MESSAGES + ### + + history_messages: list[ChatCompletionMessageParam] = [] + before_message_id = new_message.id + history_token_budget = TokenBudget(token_budget.remaining()) + + # Fetch messages from the workbench in batches that will fit our token budget. + under_budget = True + while under_budget: + # Get a batch of messages + messages_response = await context.get_messages( + before=before_message_id, + limit=100, + message_types=[MessageType.chat], + ) + messages_list = messages_response.messages + if not messages_list or len(messages_list) == 0: + break + before_message_id = messages_list[0].id + + for msg in reversed(messages_list): + if msg.sender.participant_id == context.assistant.id: + # For assistant messages, include help suggestions as part of the message content + message_content = format_message(participants, msg) + if msg.metadata and "help" in msg.metadata: + message_content += f"\n\n[Next step?: {msg.metadata['help']}]" + + current_message = ChatCompletionAssistantMessageParam( + role="assistant", + content=message_content, + ) + else: + current_message = ChatCompletionUserMessageParam( + role="user", + content=format_message(participants, msg), + ) + + current_message_tokens = num_tokens_from_messages( + model=model, + messages=[current_message], + ) + + if history_token_budget.fits(current_message_tokens): + history_messages = [current_message, *history_messages] + history_token_budget.add(current_message_tokens) + else: + under_budget = False + break + + if not under_budget: + break + + # Add all chat messages. + completion_messages.extend(history_messages) + completion_messages.append(user_message) + + # Add a system message to indicate attachments are a part of the new message. + if new_message.filenames and len(new_message.filenames) > 0: + attachment_message = ChatCompletionSystemMessageParam( + role="system", + content=f"Attachment(s): {', '.join(new_message.filenames)}", + ) + completion_messages.append(attachment_message) + token_budget.add( + num_tokens_from_messages( + model=model, + messages=[attachment_message], + ) + ) + + ## + ## Final token count check + ## + token_counts = {"total": token_budget.used, "max": token_budget.budget} + metadata["debug"]["token_usage"] = token_counts # For debug. + metadata["token_counts"] = token_counts # For footer. + if token_budget.remaining() < 0: + raise ValueError( + f"You've exceeded the token limit of {token_budget.budget} in this conversation " + f"({token_budget.used}). Try removing some attachments." + ) + + ## + ## MAKE THE LLM CALL + ## + + content = "" + async with openai_client.create_client(config.service_config) as client: + try: + completion_args = { + "messages": completion_messages, + "model": model, + "max_tokens": config.request_config.response_tokens, + "response_format": CoordinatorOutput if role == ConversationRole.COORDINATOR else TeamOutput, + } + + share_tools = ShareTools(context) + tool_functions = ( + share_tools.conversationalist_tools() + if role == ConversationRole.COORDINATOR + else share_tools.team_tools() + ) + response_start_time = time.time() + completion_response, _ = await complete_with_tool_calls( + async_client=client, + completion_args=completion_args, + tool_functions=tool_functions, + metadata=metadata["debug"], + max_tool_call_rounds=32, + ) + response_end_time = time.time() + footer_items = [] + + # Add the token usage message to the footer items + if completion_response: + response_tokens = completion_response.usage.completion_tokens if completion_response.usage else 0 + request_tokens = token_budget.used + footer_items.append( + get_token_usage_message( + max_tokens=config.request_config.max_tokens, + total_tokens=request_tokens + response_tokens, + request_tokens=request_tokens, + completion_tokens=response_tokens, + ) + ) + + await context.update_conversation( + metadata={ + "token_counts": { + "total": request_tokens + response_tokens, + "max": config.request_config.max_tokens, + } + } + ) + + footer_items.append(get_response_duration_message(response_end_time - response_start_time)) + metadata["footer_items"] = footer_items + return assistant_message_from_completion(completion_response) if completion_response else None + + except CompletionError as e: + logger.exception(f"Exception occurred calling OpenAI chat completion: {e}") + metadata["debug"]["error"] = str(e) + if isinstance(e.body, dict) and "message" in e.body: + content = e.body.get("message", e.message) + elif e.message: + content = e.message + else: + content = "An error occurred while processing your request." + await context.send_messages( + NewConversationMessage( + content=content, + message_type=MessageType.notice, + metadata=metadata, + ) + ) + return + + +def get_formatted_token_count(tokens: int) -> str: + # if less than 1k, return the number of tokens + # if greater than or equal to 1k, return the number of tokens in k + # use 1 decimal place for k + # drop the decimal place if the number of tokens in k is a whole number + if tokens < 1000: + return str(tokens) + else: + tokens_in_k = tokens / 1000 + if tokens_in_k.is_integer(): + return f"{int(tokens_in_k)}k" + else: + return f"{tokens_in_k:.1f}k" + + +def get_token_usage_message( + max_tokens: int, + total_tokens: int, + request_tokens: int, + completion_tokens: int, +) -> str: + """ + Generate a display friendly message for the token usage, to be added to the footer items. + """ + + return dedent(f""" + Tokens used: {get_formatted_token_count(total_tokens)} + ({get_formatted_token_count(request_tokens)} in / {get_formatted_token_count(completion_tokens)} out) + of {get_formatted_token_count(max_tokens)} ({int(total_tokens / max_tokens * 100)}%) + """).strip() + + +def get_response_duration_message(response_duration: float) -> str: + """ + Generate a display friendly message for the response duration, to be added to the footer items. + """ + + return f"Response time: {response_duration:.2f} seconds" diff --git a/assistants/project-assistant/assistant/agentic/team_welcome.py b/assistants/project-assistant/assistant/agentic/team_welcome.py new file mode 100644 index 000000000..a4f098779 --- /dev/null +++ b/assistants/project-assistant/assistant/agentic/team_welcome.py @@ -0,0 +1,112 @@ +""" +Analysis and detection functions for the knowledge transfer assistant. + +This module contains functions for analyzing messages and knowledge transfer +share content to detect specific conditions, such as information request needs. +""" + +from textwrap import dedent +from typing import Any + +import openai_client +from openai.types.chat import ChatCompletionMessageParam +from semantic_workbench_assistant.assistant_app import ConversationContext + +from assistant.config import assistant_config +from assistant.domain.share_manager import ShareManager +from assistant.logging import logger +from assistant.utils import load_text_include + + +async def generate_team_welcome_message( + context: ConversationContext, +) -> tuple[str, dict[str, Any]]: + """ + Generates a welcome message for the team based on the knowledge transfer information. + """ + debug: dict[str, Any] = {} + + config = await assistant_config.get(context.assistant) + + try: + share = await ShareManager.get_share(context) + except Exception as e: + logger.error(f"Failed to get share for welcome message generation: {e}") + return config.team_config.default_welcome_message, debug + + share_data: dict[str, str] = {} + + # Knowledge Brief + briefing = share.brief + brief_text = "" + if briefing: + brief_text = dedent(f""" + ### Knowledge Brief + + #### {briefing.title} + + {briefing.content} + """) + share_data["briefing"] = brief_text + + # Learning Objectives + if share and share.learning_objectives: + brief_text += "\n#### LEARNING OBJECTIVES:\n\n" + + for i, objective in enumerate(share.learning_objectives): + brief_text += f"{i + 1}. **{objective.name}** - {objective.description}\n" + if objective.learning_outcomes: + for criterion in objective.learning_outcomes: + check = "⬜" + brief_text += f" {check} {criterion.description}\n" + brief_text += "\n" + share_data["learning_objectives"] = brief_text + + # Knowledge Digest + knowledge_digest = share.digest + if knowledge_digest and knowledge_digest.content: + knowledge_digest_text = load_text_include("knowledge_digest_instructions.txt") + dedent(f""" + KNOWLEDGE DIGEST CONTENT: + ```markdown + {knowledge_digest.content} + ``` + + """) + share_data["knowledge_digest"] = knowledge_digest_text + share_data["knowledge_digest"] = knowledge_digest_text + + try: + # Chat completion + async with openai_client.create_client(config.service_config) as client: + share_info = "\n\n## KNOWLEDGE SHARE INFORMATION\n\n" + "\n".join(share_data.values()) + + instructions = f"{config.prompt_config.welcome_message_generation}\n\n{share_info}" + messages: list[ChatCompletionMessageParam] = [ + {"role": "system", "content": instructions}, + ] + + completion_args = { + "model": config.request_config.openai_model, + "messages": messages, + "max_tokens": 500, + "temperature": 0.7, # Low temperature for more consistent analysis + } + debug["completion_args"] = openai_client.serializable(completion_args) + + # LLM call + response = await client.chat.completions.create( + **completion_args, + ) + debug["completion_response"] = response.model_dump() + + # Response + if response and response.choices and response.choices[0].message.content: + return response.choices[0].message.content, debug + else: + logger.warning("Empty response from LLM for welcome message generation") + return config.team_config.default_welcome_message, debug + + except Exception as e: + logger.error(f"Failed to generate welcome message: {e}") + debug["error"] = str(e) + return config.team_config.default_welcome_message, debug diff --git a/assistants/project-assistant/assistant/agentic/update_digest.py b/assistants/project-assistant/assistant/agentic/update_digest.py new file mode 100644 index 000000000..180fa440a --- /dev/null +++ b/assistants/project-assistant/assistant/agentic/update_digest.py @@ -0,0 +1,101 @@ +import re +from typing import Any +from venv import logger + +import openai_client +from assistant_extensions.attachments import AttachmentsExtension +from semantic_workbench_assistant.assistant_app import ConversationContext + +from assistant.config import assistant_config +from assistant.data import InspectorTab +from assistant.domain import KnowledgeDigestManager +from assistant.domain.share_manager import ShareManager +from assistant.notifications import Notifications +from assistant.prompt_utils import ContextSection, ContextStrategy, Instructions, Prompt, add_context_to_prompt + + +async def update_digest(context: ConversationContext, attachments_extension: AttachmentsExtension) -> None: + debug: dict[str, Any] = { + "context": context.to_dict(), + } + + config = await assistant_config.get(context.assistant) + + # Set up prompt instructions. + instruction_text = config.prompt_config.update_knowledge_digest + instructions = Instructions(instruction_text) + prompt = Prompt( + instructions=instructions, + context_strategy=ContextStrategy.MULTI, + ) + + # Add prompt context. + role = await ShareManager.get_conversation_role(context) + await add_context_to_prompt( + prompt, + context=context, + role=role, + model=config.request_config.openai_model, + token_limit=config.request_config.max_tokens, + attachments_extension=attachments_extension, + attachments_config=config.attachments_config, + attachments_in_system_message=True, + include=[ + # ContextSection.KNOWLEDGE_INFO, + ContextSection.KNOWLEDGE_BRIEF, + # ContextSection.TASKS, + ContextSection.TARGET_AUDIENCE, + ContextSection.LEARNING_OBJECTIVES, + ContextSection.KNOWLEDGE_DIGEST, + # ContextSection.INFORMATION_REQUESTS, + # ContextSection.SUGGESTED_NEXT_ACTIONS, + ContextSection.COORDINATOR_CONVERSATION, + ContextSection.ATTACHMENTS, + ], + ) + + async with openai_client.create_client(config.service_config, api_version="2024-06-01") as client: + try: + completion_args = { + "messages": prompt.messages(), + "model": config.request_config.openai_model, + "max_tokens": config.coordinator_config.max_digest_tokens, + "temperature": 0.7, + } + debug["completion_args"] = openai_client.serializable(completion_args) + response = await client.chat.completions.create(**completion_args) + openai_client.validate_completion(response) + debug["completion_response"] = openai_client.serializable(response.model_dump()) + + # Extract the knowledge digest content from the response. + content = response.choices[0].message.content or "" + + if content == "<OK_AS_IS/>": + logger.info("Knowledge digest is already up to date, no changes made.", extra={"debug": debug}) + return + + match = re.search(r"<KNOWLEDGE_DIGEST>(.*?)</KNOWLEDGE_DIGEST>", content, re.DOTALL) + digest_content = match.group(1).strip() if match else content + if not digest_content: + logger.error("No content extracted from knowledge digest LLM analysis", extra={"debug": debug}) + debug["digest_content"] = digest_content + + # Save the knowledge digest. + await KnowledgeDigestManager.update_knowledge_digest( + context=context, + content=digest_content, + is_auto_generated=True, + ) + + # Use this for debugging in the Semantic Workbench UI. + await Notifications.notify(context, "Updated knowledge digest.", debug_data=debug) + await Notifications.notify_state_update( + context, + [InspectorTab.DEBUG], + ) + + except Exception as e: + debug["error"] = str(e) + logger.exception(f"Failed to make OpenIA call: {e}", extra={"debug": debug}) + + # logger.debug(f"{__name__}: {debug}") diff --git a/assistants/project-assistant/assistant/assets/icon_context_transfer.svg b/assistants/project-assistant/assistant/assets/icon-knowledge-transfer.svg similarity index 100% rename from assistants/project-assistant/assistant/assets/icon_context_transfer.svg rename to assistants/project-assistant/assistant/assets/icon-knowledge-transfer.svg diff --git a/assistants/project-assistant/assistant/assistant.py b/assistants/project-assistant/assistant/assistant.py index 11c8835c4..b3808af49 100644 --- a/assistants/project-assistant/assistant/assistant.py +++ b/assistants/project-assistant/assistant/assistant.py @@ -4,8 +4,8 @@ import asyncio import pathlib -from enum import Enum -from typing import Any +import re +from typing import TYPE_CHECKING, Any from assistant_extensions import attachments, dashboard_card, navigator from content_safety.evaluators import CombinedContentSafetyEvaluator @@ -27,31 +27,31 @@ ConversationContext, ) -from assistant.command_processor import command_registry -from assistant.respond import respond_to_conversation -from assistant.team_welcome import generate_team_welcome_message -from assistant.utils import ( - DEFAULT_TEMPLATE_ID, - load_text_include, -) +if TYPE_CHECKING: + pass +from .agentic import agentic +from .agentic.respond import ( + SILENCE_TOKEN, + CoordinatorOutput, + TeamOutput, + respond_to_conversation, +) from .config import assistant_config -from .conversation_project_link import ConversationProjectManager +from .data import ConversationRole, InspectorTab, LogEntryType +from .domain import ShareManager +from .files import ShareFilesManager from .logging import logger -from .project_common import detect_assistant_role -from .project_data import LogEntryType -from .project_files import ProjectFileManager -from .project_manager import ProjectManager -from .project_notifications import ProjectNotifier -from .project_storage import ProjectStorage -from .project_storage_models import ConversationRole -from .state_inspector import ProjectInspectorStateProvider +from .notifications import Notifications +from .ui_tabs import BriefInspector, DebugInspector, LearningInspector, SharingInspector +from .utils import ( + DEFAULT_TEMPLATE_ID, + load_text_include, +) service_id = "project-assistant.made-exploration" -service_name = "Project Assistant" -service_description = ( - "A mediator assistant that facilitates project management between project coordinators and a team." -) +service_name = "Project Assistant (KTA)" +service_description = "A mediator assistant that facilitates sharing knowledge between parties." async def content_evaluator_factory( @@ -71,17 +71,20 @@ async def content_evaluator_factory( content_interceptor=content_safety, capabilities={AssistantCapability.supports_conversation_files}, inspector_state_providers={ - "project_status": ProjectInspectorStateProvider(assistant_config), + InspectorTab.BRIEF: BriefInspector(assistant_config), + InspectorTab.LEARNING: LearningInspector(assistant_config), + InspectorTab.SHARING: SharingInspector(assistant_config), + InspectorTab.DEBUG: DebugInspector(assistant_config), }, - additional_templates=[], assistant_service_metadata={ **dashboard_card.metadata( dashboard_card.TemplateConfig( - enabled=False, + enabled=True, template_id=DEFAULT_TEMPLATE_ID, - background_color="rgb(140, 200, 140)", + background_color="rgb(198, 177, 222)", icon=dashboard_card.image_to_url( - pathlib.Path(__file__).parent / "assets" / "icon.svg", "image/svg+xml" + pathlib.Path(__file__).parent / "assets" / "icon-knowledge-transfer.svg", + "image/svg+xml", ), card_content=dashboard_card.CardContent( content_type="text/markdown", @@ -90,7 +93,7 @@ async def content_evaluator_factory( ), ), **navigator.metadata_for_assistant_navigator({ - "default": load_text_include("project_assistant_info.md"), + "default": load_text_include("assistant_info.md"), }), }, ) @@ -100,66 +103,91 @@ async def content_evaluator_factory( app = assistant.fastapi_app() -class ConversationType(Enum): - COORDINATOR = "coordinator" - TEAM = "team" - SHAREABLE_TEMPLATE = "shareable_template" - - @assistant.events.conversation.on_created_including_mine async def on_conversation_created(context: ConversationContext) -> None: """ The assistant manages three types of conversations: - 1. Coordinator Conversation: The main conversation used by the project coordinator + 1. Coordinator Conversation: The main conversation used by the knowledge coordinator 2. Shareable Team Conversation: A template conversation that has a share URL and is never directly used 3. Team Conversation(s): Individual conversations for team members created when they redeem the share URL """ - # Get conversation to access metadata + conversation = await context.get_conversation() + + # We can't pick up the role from the share data yet, so + # we need to determine the role based on the conversation metadata. conversation_metadata = conversation.metadata or {} + share_id = conversation_metadata.get("share_id") + if not share_id: + role = ConversationRole.COORDINATOR + else: + if conversation.imported_from_conversation_id: + role = ConversationRole.TEAM + else: + role = ConversationRole.SHAREABLE_TEMPLATE - config = await assistant_config.get(context.assistant) + # Now handle the new conversation based on its role. + match role: + case ConversationRole.COORDINATOR: + try: + # In the beginning, we created a share... + share_id = await ShareManager.create_share(context) + + # And it was good. So we then created a sharable conversation that we use as a template. + share_url = await ShareManager.create_shareable_team_conversation(context=context, share_id=share_id) + + # Pop open the inspector panel. + await context.send_conversation_state_event( + AssistantStateEvent( + state_id="brief", + event="focus", + state=None, + ) + ) - ## - ## Figure out what type of conversation this is. - ## + # Run task-detection agents. + audience_task = asyncio.create_task( + agentic.detect_audience_and_takeaways(context, attachments_extension) + ) + gaps_task = asyncio.create_task(agentic.detect_knowledge_package_gaps(context, attachments_extension)) + audience_task.add_done_callback(lambda t: t.exception() if t.done() and t.exception() else None) + gaps_task.add_done_callback(lambda t: t.exception() if t.done() and t.exception() else None) + await asyncio.gather(audience_task, gaps_task, return_exceptions=True) + + # Kick off the task actor. + metadata: dict[str, Any] = { + "debug": { + "share_id": share_id, + } + } + await agentic.act(context, attachments_extension, metadata) + + # Prepare a generic welcome message. + config = await assistant_config.get(context.assistant) + welcome_message = config.coordinator_config.welcome_message.format( + share_url=share_url or "<Share URL generation failed>" + ) + except Exception as e: + welcome_message = f"I'm having trouble setting up your knowledge transfer. Please try again or contact support if the issue persists. {e!s}" # noqa: E501 - conversation_type = ConversationType.COORDINATOR + await context.send_messages( + NewConversationMessage( + content=welcome_message, + message_type=MessageType.chat, + ) + ) - # Coordinator conversations will not have a project_id or - # is_team_conversation flag in the metadata. So, if they are there, we just - # need to decide if it's a shareable template or a team conversation. - project_id = conversation_metadata.get("project_id") - if conversation_metadata.get("is_team_conversation", False) and project_id: - # If this conversation was imported from another, it indicates it's from - # share redemption. - if conversation.imported_from_conversation_id: - conversation_type = ConversationType.TEAM - # TODO: This might work better for detecting a redeemed link, but - # hasn't been validated. - - # if conversation_metadata.get("share_redemption") and conversation_metadata.get("share_redemption").get( - # "conversation_share_id" - # ): - # conversation_type = ConversationType.TEAM - else: - conversation_type = ConversationType.SHAREABLE_TEMPLATE - - ## - ## Handle the conversation based on its type - ## - match conversation_type: - case ConversationType.SHAREABLE_TEMPLATE: - if not project_id: - logger.error("No project ID found for shareable team conversation.") + case ConversationRole.SHAREABLE_TEMPLATE: + # Associate the shareable template with a share ID + if not share_id: + logger.error("No share ID found for shareable team conversation.") return - - await ConversationProjectManager.associate_conversation_with_project(context, project_id) + await ShareManager.set_conversation_role(context, share_id, ConversationRole.SHAREABLE_TEMPLATE) return - case ConversationType.TEAM: - if not project_id: - logger.error("No project ID found for team conversation.") + case ConversationRole.TEAM: + if not share_id: + logger.error("No share ID found for team conversation.") return # I'd put status messages here, but the attachment's extension is causing race conditions. @@ -170,13 +198,10 @@ async def on_conversation_created(context: ConversationContext) -> None: ) ) - await ConversationProjectManager.associate_conversation_with_project(context, project_id) - - # Synchronize files. - await ProjectFileManager.synchronize_files_to_team_conversation(context=context, project_id=project_id) + await ShareManager.set_conversation_role(context, share_id, ConversationRole.TEAM) + await ShareFilesManager.synchronize_files_to_team_conversation(context=context, share_id=share_id) - # Generate a welcome message. - welcome_message, debug = await generate_team_welcome_message(context) + welcome_message, debug = await agentic.generate_team_welcome_message(context) await context.send_messages( NewConversationMessage( content=welcome_message, @@ -191,7 +216,7 @@ async def on_conversation_created(context: ConversationContext) -> None: # Pop open the inspector panel. await context.send_conversation_state_event( AssistantStateEvent( - state_id="project_status", + state_id="brief", event="focus", state=None, ) @@ -199,37 +224,61 @@ async def on_conversation_created(context: ConversationContext) -> None: return - case ConversationType.COORDINATOR: - try: - project_id = await ProjectManager.create_project(context) - # A basic brief to start with. +@assistant.events.conversation.on_updated +async def on_conversation_updated(context: ConversationContext) -> None: + """ + Handle conversation updates (including title changes) and sync with shareable template. + """ + try: + conversation = await context.get_conversation() + role = await ShareManager.get_conversation_role(context) + if role != ConversationRole.COORDINATOR: + return - await ProjectManager.update_project_brief( - context=context, - title=f"New {config.Project_or_Context}", - description="_This project brief is displayed in the side panel of all of your team members' conversations, too. Before you share links to your team, ask your assistant to update the brief with whatever details you'd like here. What will help your teammates get off to a good start as they begin working on your project?_", - ) + shared_conversation_id = await ShareManager.get_shared_conversation_id(context) + if not shared_conversation_id: + return - # Create a team conversation with a share URL - share_url = await ProjectManager.create_shareable_team_conversation( - context=context, project_id=project_id + # Update the shareable template conversation's title if needed. + try: + target_context = context.for_conversation(shared_conversation_id) + target_conversation = await target_context.get_conversation() + if target_conversation.title != conversation.title: + await target_context.update_conversation_title(conversation.title) + logger.debug( + f"Updated conversation {shared_conversation_id} title from '{target_conversation.title}' to '{conversation.title}'" # noqa: E501 ) + else: + logger.debug(f"Conversation {shared_conversation_id} title already matches: '{conversation.title}'") + except Exception as title_update_error: + logger.error(f"Error updating conversation {shared_conversation_id} title: {title_update_error}") - welcome_message = config.coordinator_config.welcome_message.format( - share_url=share_url or "<Share URL generation failed>" - ) + except Exception as e: + logger.error(f"Error syncing conversation title: {e}") - except Exception as e: - welcome_message = f"I'm having trouble setting up your project. Please try again or contact support if the issue persists. {str(e)}" - # Send the welcome message - await context.send_messages( - NewConversationMessage( - content=welcome_message, - message_type=MessageType.chat, +async def store_coordinator_message(context: ConversationContext, message: ConversationMessage) -> None: + async with context.set_status("jotting..."): + try: + sender_name = "Coordinator" + if message.sender: + participants = await context.get_participants() + for participant in participants.participants: + if participant.id == message.sender.participant_id: + sender_name = participant.name + break + + await ShareManager.append_coordinator_message( + context=context, + message_id=str(message.id), + content=message.content, + sender_name=sender_name, + is_assistant=message.sender.participant_role == ParticipantRole.assistant, + timestamp=message.timestamp, ) - ) + except Exception as e: + logger.exception(f"Error storing Coordinator message for Team access: {e}") @assistant.events.conversation.message.chat.on_created @@ -245,54 +294,184 @@ async def on_message_created( } try: - project_id = await ProjectManager.get_project_id(context) - metadata["debug"]["project_id"] = project_id + share = await ShareManager.get_share(context) + metadata["debug"]["share_id"] = share.share_id + is_user_message = message.sender.participant_role == ParticipantRole.user + user_information_requests: list[str] | None = None + + # Save coordinator-role user messages for team access. + role = await ShareManager.get_conversation_role(context) + if role == ConversationRole.COORDINATOR and is_user_message: + await store_coordinator_message(context, message) + + if message.message_type == MessageType.chat and is_user_message: + async with context.set_status("pondering..."): + if role == ConversationRole.COORDINATOR: + # Update knowledge digest. + digest_task = asyncio.create_task(agentic.update_digest(context, attachments_extension)) + digest_task.add_done_callback(lambda t: t.exception() if t.done() and t.exception() else None) + + parallel_tasks = [] + + # Solicit audience and audience takeaway tasks. + audience_task = asyncio.create_task( + agentic.detect_audience_and_takeaways(context, attachments_extension) + ) + audience_task.add_done_callback(lambda t: t.exception() if t.done() and t.exception() else None) + parallel_tasks.append(audience_task) + + # Solicit knowledge package gap tasks. + gaps_task = asyncio.create_task( + agentic.detect_knowledge_package_gaps(context, attachments_extension) + ) + gaps_task.add_done_callback(lambda t: t.exception() if t.done() and t.exception() else None) + parallel_tasks.append(gaps_task) + + # Detect coordinator actions + # coordinator_actions_task = asyncio.create_task( + # agentic.detect_coordinator_actions(context, attachments_extension) + # ) + # coordinator_actions_task.add_done_callback( + # lambda t: t.exception() if t.done() and t.exception() else None + # ) + + # Run all of the tasks in parallel and wait for their return. + await asyncio.gather(*parallel_tasks, return_exceptions=True) + + # task5 = asyncio.create_task(agentic.focus(context, attachments_extension)) + # task5.add_done_callback(lambda t: t.exception() if t.done() and t.exception() else None) + + # Now, let's act on all the tasks. + await agentic.act(context, attachments_extension, metadata) + + if role == ConversationRole.TEAM: + # For team role, analyze message for possible information request needs. + # Send a notification if we think it might be one. + detection_result = await agentic.detect_information_request_needs(context, message.content) + + if ( + detection_result.get("is_information_request", False) + and detection_result.get("confidence", 0) > 0.8 + ): + suggested_title = detection_result.get("potential_title", "") + suggested_priority = detection_result.get("suggested_priority", "medium") + potential_description = detection_result.get("potential_description", "") + reason = detection_result.get("reason", "") + + # TODO: replace this with the sub-agent creating tasks. + await context.send_messages( + NewConversationMessage( + content=( + f"**Potential _Information Request_ Detected**\n\n" + f"You might need information from the knowledge coordinator. {reason}\n\n" + f"Would you like me to create an information request?\n" + f"**Title:** {suggested_title}\n" + f"**Description:** {potential_description}\n" + f"**Priority:** {suggested_priority}\n\n" + ), + message_type=MessageType.notice, + metadata={"debug": detection_result}, + ) + ) + + # Generate message. + response = await respond_to_conversation( + context, + new_message=message, + attachments_extension=attachments_extension, + metadata=metadata, + user_information_requests=user_information_requests, + ) + content = "" + if response: + content = str(response.get("content", "")) + + # strip out the username from the response + if content.startswith("["): + content = re.sub(r"\[.*\]:\s", "", content) + + # If there are more than one user participants in the conversation, we need + # to check if the model chose to remain silent. + if content and content.replace(" ", "") == SILENCE_TOKEN: + config = await assistant_config.get(context.assistant) + if config.enable_debug_output: + metadata["debug"]["silence_token"] = True + metadata["debug"]["silence_token_response"] = (content,) + await context.send_messages( + NewConversationMessage( + message_type=MessageType.notice, + content="[assistant chose to remain silent]", + metadata=metadata, + ) + ) + return - # If this is a Coordinator conversation, store the message for Team access - async with context.set_status("jotting..."): - role = await detect_assistant_role(context) - if role == ConversationRole.COORDINATOR and message.message_type == MessageType.chat: + # Prepare response. + response_parts: list[str] = [] + if not content: + return try: - if project_id: - # Get the sender's name - sender_name = "Coordinator" - if message.sender: - participants = await context.get_participants() - for participant in participants.participants: - if participant.id == message.sender.participant_id: - sender_name = participant.name - break - - # Store the message for Team access - ProjectStorage.append_coordinator_message( - project_id=project_id, - message_id=str(message.id), - content=message.content, - sender_name=sender_name, - is_assistant=message.sender.participant_role == ParticipantRole.assistant, - timestamp=message.timestamp, + if role == ConversationRole.TEAM: + output_model = TeamOutput.model_validate_json(content) + if output_model.response: + response_parts.append(output_model.response) + + if output_model.excerpt: + output_model.excerpt = output_model.excerpt.strip().strip('"') + response_parts.append(f'> _"{output_model.excerpt}"_ (excerpt)') + + if output_model.citations: + citations = ", ".join(output_model.citations) + response_parts.append(f"Sources: _{citations}_") + + if output_model.next_step_suggestion: + metadata["help"] = output_model.next_step_suggestion + + if role == ConversationRole.COORDINATOR: + output_model = CoordinatorOutput.model_validate_json(content) + if output_model.response: + response_parts.append(output_model.response) + # if output_model.next_step_suggestion: + # metadata["help"] = output_model.next_step_suggestion + + await context.send_messages( + NewConversationMessage( + content="\n\n".join(response_parts), + message_type=MessageType.chat, + metadata=metadata, ) - except Exception as e: - # Don't fail message handling if storage fails - logger.exception(f"Error storing Coordinator message for Team access: {e}") - - async with context.set_status("pondering..."): - await respond_to_conversation( - context, - new_message=message, - attachments_extension=attachments_extension, - metadata=metadata, - ) + ) + + # Save valid assistant responses for team access. + await store_coordinator_message( + context, + ConversationMessage( + id=message.id, + content_type=message.content_type, + content="\n\n".join(response_parts), + sender=message.sender, + timestamp=message.timestamp, + message_type=MessageType.chat, + filenames=[], + metadata={}, + has_debug_data=False, + ), + ) - # If the message is from a Coordinator, update the whiteboard in the background - if role == ConversationRole.COORDINATOR and message.message_type == MessageType.chat: - asyncio.create_task(ProjectManager.auto_update_whiteboard(context)) + except Exception as e: + metadata["debug"]["error"] = str(e) + logger.exception(f"exception occurred parsing json response: {e}") + NewConversationMessage( + content="I'm sorry, I encountered an error while processing the response.", + message_type=MessageType.notice, + metadata=metadata, + ) except Exception as e: logger.exception(f"Error handling message: {e}") await context.send_messages( NewConversationMessage( - content=f"Error: {str(e)}", + content=f"Error: {e!s}", message_type=MessageType.notice, metadata={"generated_content": False, **metadata}, ) @@ -312,20 +491,14 @@ async def on_command_created( try: metadata = {"debug": {"content_safety": event.data.get(content_safety.metadata_key, {})}} - # Process the command using the command processor - role = await detect_assistant_role(context) - command_processed = await command_registry.process_command(context, message, role.value) - - # If the command wasn't recognized or processed, respond normally - if not command_processed: - await respond_to_conversation( - context, - new_message=message, - attachments_extension=attachments_extension, - metadata=metadata, - ) + # Respond to the conversation + await respond_to_conversation( + context, + new_message=message, + attachments_extension=attachments_extension, + metadata=metadata, + ) finally: - # update the participant status to indicate the assistant is done thinking await context.update_participant_me(UpdateParticipant(status=None)) @@ -339,74 +512,62 @@ async def on_file_created( Handle when a file is created in the conversation. For Coordinator files: - 1. Store a copy in project storage + 1. Store a copy in share storage 2. Synchronize to all Team conversations For Team files: - 1. Use as-is without copying to project storage + 1. Use as-is without copying to share storage """ try: - project_id = await ProjectManager.get_project_id(context) - if not project_id or not file.filename: - logger.warning( - f"No project ID found or missing filename: project_id={project_id}, filename={file.filename}" - ) + if not file.filename: + logger.warning(f"No share found or missing filename. filename={file.filename}") return - role = await detect_assistant_role(context) - - # Use ProjectFileManager for file operations + share = await ShareManager.get_share(context) + role = await ShareManager.get_conversation_role(context) # Process based on role if role == ConversationRole.COORDINATOR: # For Coordinator files: - # 1. Store in project storage (marked as coordinator file) + # 1. Store in share storage (marked as coordinator file) - success = await ProjectFileManager.copy_file_to_project_storage( + success = await ShareFilesManager.copy_file_to_share_storage( context=context, - project_id=project_id, + share_id=share.share_id, file=file, is_coordinator_file=True, ) if not success: - logger.error(f"Failed to copy file to project storage: {file.filename}") + logger.error(f"Failed to copy file to share storage: {file.filename}") return # 2. Synchronize to all Team conversations # Get all Team conversations - team_conversations = await ProjectFileManager.get_team_conversations(context, project_id) + team_conversations = await ShareFilesManager.get_team_conversations(context, share.share_id) if team_conversations: for team_conv_id in team_conversations: - await ProjectFileManager.copy_file_to_conversation( + await ShareFilesManager.copy_file_to_conversation( context=context, - project_id=project_id, + share_id=share.share_id, filename=file.filename, target_conversation_id=team_conv_id, ) # 3. Update all UIs but don't send notifications to reduce noise - await ProjectNotifier.notify_project_update( - context=context, - project_id=project_id, - update_type="file_created", - message=f"Coordinator shared a file: {file.filename}", - data={"filename": file.filename}, - send_notification=False, # Don't send notification to reduce noise - ) + await Notifications.notify_all_state_update(context, [InspectorTab.DEBUG]) # Team files don't need special handling as they're already in the conversation - # Log file creation to project log for all files - await ProjectStorage.log_project_event( + # Log file creation to knowledge transfer log for all files + await ShareManager.log_share_event( context=context, - project_id=project_id, entry_type="file_shared", message=f"File shared: {file.filename}", metadata={ "file_id": getattr(file, "id", ""), "filename": file.filename, - "is_coordinator_file": role.value == "coordinator", + "is_coordinator_file": role == ConversationRole.COORDINATOR, }, ) @@ -421,56 +582,45 @@ async def on_file_updated( file: workbench_model.File, ) -> None: try: - # Get project ID - project_id = await ProjectManager.get_project_id(context) - if not project_id or not file.filename: + if not file.filename: return - role = await detect_assistant_role(context) + share = await ShareManager.get_share(context) + role = await ShareManager.get_conversation_role(context) if role == ConversationRole.COORDINATOR: # For Coordinator files: - # 1. Update in project storage - success = await ProjectFileManager.copy_file_to_project_storage( + # 1. Update in share storage + success = await ShareFilesManager.copy_file_to_share_storage( context=context, - project_id=project_id, + share_id=share.share_id, file=file, is_coordinator_file=True, ) if not success: - logger.error(f"Failed to update file in project storage: {file.filename}") + logger.error(f"Failed to update file in share storage: {file.filename}") return - team_conversations = await ProjectFileManager.get_team_conversations(context, project_id) + team_conversations = await ShareFilesManager.get_team_conversations(context, share.share_id) for team_conv_id in team_conversations: - await ProjectFileManager.copy_file_to_conversation( + await ShareFilesManager.copy_file_to_conversation( context=context, - project_id=project_id, + share_id=share.share_id, filename=file.filename, target_conversation_id=team_conv_id, ) # 3. Update all UIs but don't send notifications to reduce noise - await ProjectNotifier.notify_project_update( - context=context, - project_id=project_id, - update_type="file_updated", - message=f"Coordinator updated a file: {file.filename}", - data={"filename": file.filename}, - send_notification=False, # Don't send notification to reduce noise - ) - # Team files don't need special handling + await Notifications.notify_all_state_update(context, [InspectorTab.DEBUG]) - # Log file update to project log for all files - await ProjectStorage.log_project_event( + await ShareManager.log_share_event( context=context, - project_id=project_id, entry_type="file_shared", message=f"File updated: {file.filename}", metadata={ "file_id": getattr(file, "id", ""), "filename": file.filename, - "is_coordinator_file": role.value == "coordinator", + "is_coordinator_file": role == ConversationRole.COORDINATOR, }, ) @@ -485,43 +635,33 @@ async def on_file_deleted( file: workbench_model.File, ) -> None: try: - # Get project ID - project_id = await ProjectManager.get_project_id(context) - if not project_id or not file.filename: + if not file.filename: return - role = await detect_assistant_role(context) + share = await ShareManager.get_share(context) + role = await ShareManager.get_conversation_role(context) if role == ConversationRole.COORDINATOR: # For Coordinator files: - # 1. Delete from project storage - success = await ProjectFileManager.delete_file_from_project_storage( - context=context, project_id=project_id, filename=file.filename + # 1. Delete from share storage + success = await ShareFilesManager.delete_file_from_knowledge_share_storage( + context=context, share_id=share.share_id, filename=file.filename ) if not success: - logger.error(f"Failed to delete file from project storage: {file.filename}") + logger.error(f"Failed to delete file from share storage: {file.filename}") # 2. Update all UIs about the deletion but don't send notifications to reduce noise - await ProjectNotifier.notify_project_update( - context=context, - project_id=project_id, - update_type="file_deleted", - message=f"Coordinator deleted a file: {file.filename}", - data={"filename": file.filename}, - send_notification=False, # Don't send notification to reduce noise - ) + await Notifications.notify_all_state_update(context, [InspectorTab.DEBUG]) # Team files don't need special handling - # Log file deletion to project log for all files - await ProjectStorage.log_project_event( + await ShareManager.log_share_event( context=context, - project_id=project_id, entry_type="file_deleted", message=f"File deleted: {file.filename}", metadata={ "file_id": getattr(file, "id", ""), "filename": file.filename, - "is_coordinator_file": role.value == "coordinator", + "is_coordinator_file": role == ConversationRole.COORDINATOR, }, ) @@ -542,25 +682,24 @@ async def on_participant_joined( # Open the Brief tab (state inspector). await context.send_conversation_state_event( AssistantStateEvent( - state_id="project_status", + state_id="brief", event="focus", state=None, ) ) - role = await detect_assistant_role(context) + role = await ShareManager.get_conversation_role(context) if role != ConversationRole.TEAM: return - project_id = await ConversationProjectManager.get_associated_project_id(context) - if not project_id: + share_id = await ShareManager.get_share_id(context) + if not share_id: return - await ProjectFileManager.synchronize_files_to_team_conversation(context=context, project_id=project_id) + await ShareFilesManager.synchronize_files_to_team_conversation(context=context, share_id=share_id) - await ProjectStorage.log_project_event( + await ShareManager.log_share_event( context=context, - project_id=project_id, entry_type=LogEntryType.PARTICIPANT_JOINED, message=f"Participant joined: {participant.name}", metadata={ diff --git a/assistants/project-assistant/assistant/command_processor.py b/assistants/project-assistant/assistant/command_processor.py deleted file mode 100644 index 58a35c892..000000000 --- a/assistants/project-assistant/assistant/command_processor.py +++ /dev/null @@ -1,1126 +0,0 @@ -""" -Command processor for the project assistant. - -This module provides a unified framework for processing commands in the project assistant. -It defines a command registry, command handlers for both Coordinator and Team modes, and authorization -controls based on user roles. -""" - -import logging -from typing import Any, Awaitable, Callable, Dict, List, Optional - -from semantic_workbench_api_model.workbench_model import ( - ConversationMessage, - MessageType, - NewConversationMessage, -) -from semantic_workbench_assistant.assistant_app import ConversationContext - -from .conversation_project_link import ConversationProjectManager -from .project_data import ( - RequestPriority, - RequestStatus, -) -from .project_manager import ProjectManager -from .project_notifications import ProjectNotifier -from .project_storage import ProjectStorage -from .project_storage_models import ConversationRole - -logger = logging.getLogger(__name__) - -# Command handler function type -CommandHandlerType = Callable[[ConversationContext, ConversationMessage, List[str]], Awaitable[None]] - - -class CommandRegistry: - """Registry for command handlers with authorization controls.""" - - def __init__(self): - """Initialize the command registry.""" - self.commands: Dict[str, Dict[str, Any]] = {} - - def register_command( - self, - command_name: str, - handler: CommandHandlerType, - description: str, - usage: str, - example: str, - authorized_roles: Optional[List[str]] = None, - ) -> None: - """ - Register a command handler. - - Args: - command_name: The command name (without the '/') - handler: The function that handles the command - description: A brief description of what the command does - usage: The command usage format - example: An example of how to use the command - authorized_roles: List of roles that can use this command (None for all) - """ - self.commands[command_name] = { - "handler": handler, - "description": description, - "usage": usage, - "example": example, - "authorized_roles": authorized_roles, - } - - def is_authorized(self, command_name: str, role: str) -> bool: - """ - Check if a role is authorized to use a command. - - Args: - command_name: The command name - role: The user role - - Returns: - True if authorized, False otherwise - """ - if command_name not in self.commands: - return False - - authorized_roles = self.commands[command_name]["authorized_roles"] - if authorized_roles is None: - return True # Command available to all roles - - return role in authorized_roles - - def get_command_help(self, command_name: str) -> Optional[Dict[str, str]]: - """ - Get help information for a command. - - Args: - command_name: The command name - - Returns: - Dictionary with help information or None if command not found - """ - if command_name not in self.commands: - return None - - return { - "description": self.commands[command_name]["description"], - "usage": self.commands[command_name]["usage"], - "example": self.commands[command_name]["example"], - } - - def get_commands_for_role(self, role: str) -> Dict[str, Dict[str, Any]]: - """ - Get all commands available for a specific role. - - Args: - role: The user role - - Returns: - Dictionary of commands available to the role - """ - return {name: cmd for name, cmd in self.commands.items() if self.is_authorized(name, role)} - - async def process_command(self, context: ConversationContext, message: ConversationMessage, role: str) -> bool: - """ - Process a command message. - - Args: - context: The conversation context - message: The command message - role: The user's role (coordinator or team) - - Returns: - True if command was processed, False otherwise - """ - # Split the command and extract the command name - content = message.content.strip() - if not content.startswith("/"): - return False - - # Extract command name and arguments - parts = content.split() - command_name = parts[0][1:] # Remove the '/' prefix - args = parts[1:] if len(parts) > 1 else [] - - # Check if command exists - if command_name not in self.commands: - await context.send_messages( - NewConversationMessage( - content=f"Unknown command: /{command_name}. Type /help to see available commands.", - message_type=MessageType.notice, - ) - ) - return True - - # Check if user is authorized to use this command - if not self.is_authorized(command_name, role): - await context.send_messages( - NewConversationMessage( - content=f"The /{command_name} command is only available to {' or '.join(self.commands[command_name]['authorized_roles'])} roles. You are in {role.upper()} mode.", - message_type=MessageType.notice, - ) - ) - return True - - try: - # Execute the command handler - await self.commands[command_name]["handler"](context, message, args) - return True - except Exception as e: - logger.exception(f"Error processing command /{command_name}: {e}") - await context.send_messages( - NewConversationMessage( - content=f"Error processing command /{command_name}: {str(e)}", - message_type=MessageType.notice, - ) - ) - return True - - -# Initialize the command registry -command_registry = CommandRegistry() - - -# Command handler implementations - - -async def handle_help_command(context: ConversationContext, message: ConversationMessage, args: List[str]) -> None: - """Handle the help command.""" - # Get the conversation's role - from .conversation_project_link import ConversationProjectManager - - # First check conversation metadata - conversation = await context.get_conversation() - metadata = conversation.metadata or {} - setup_complete = metadata.get("setup_complete", False) - assistant_mode = metadata.get("assistant_mode", "setup") - metadata_role = metadata.get("project_role") - - # First check if project ID exists - if it does, setup should be considered complete - project_id = await ProjectManager.get_project_id(context) - if project_id: - # If we have a project ID, we should never show the setup instructions - setup_complete = True - - # If metadata doesn't reflect this, try to get actual role - if not metadata.get("setup_complete", False): - role = await ConversationProjectManager.get_conversation_role(context) - if role: - metadata_role = role.value - else: - # Default to team mode if we can't determine role - metadata_role = "team" - - # Special handling for setup mode - only if we truly have no project - if not setup_complete and assistant_mode == "setup" and not project_id: - # If a specific command is specified, show detailed help for that command - if args: - command_name = args[0] - if command_name.startswith("/"): - command_name = command_name[1:] # Remove the '/' prefix - - # For setup mode, only show help for setup commands - setup_commands = ["start-coordinator", "join", "help"] - - if command_name in setup_commands: - help_info = command_registry.get_command_help(command_name) - if help_info: - await context.send_messages( - NewConversationMessage( - content=f"""## Help: /{command_name} - -{help_info["description"]} - -**Usage:** {help_info["usage"]} - -**Example:** {help_info["example"]} -""", - message_type=MessageType.chat, - ) - ) - return - - # If not a setup command, show generic message - await context.send_messages( - NewConversationMessage( - content=f"The /{command_name} command is not available in setup mode. Please first use `/start-coordinator` or `/join` to establish your role.", - message_type=MessageType.notice, - ) - ) - return - - # Show setup-specific help - help_text = """## Project Assistant - -This assistant is automatically set up to help you with your project: - -- As a Coordinator: This conversation is your personal conversation for managing the project -- As a Team Member: This conversation is for collaborating on the project with others - -No setup commands needed! You're already good to go. - -Type `/help` to see all available commands for your role. -""" - - await context.send_messages( - NewConversationMessage( - content=help_text, - message_type=MessageType.chat, - ) - ) - return - - # Normal (non-setup) help processing - # Use the role from metadata, which is always the authoritative source - # Log the role for debugging - logger.debug(f"Role detection in help command - Metadata role: {metadata_role}") - - # Use the role from metadata or default to coordinator - role = metadata_role or "coordinator" # Default to coordinator if not set - - # If a specific command is specified, show detailed help for that command - if args: - command_name = args[0] - if command_name.startswith("/"): - command_name = command_name[1:] # Remove the '/' prefix - - help_info = command_registry.get_command_help(command_name) - - if help_info and command_registry.is_authorized(command_name, role): - await context.send_messages( - NewConversationMessage( - content=f"""## Help: /{command_name} - -{help_info["description"]} - -**Usage:** {help_info["usage"]} - -**Example:** {help_info["example"]} -""", - message_type=MessageType.chat, - ) - ) - else: - await context.send_messages( - NewConversationMessage( - content=f"No help available for command /{command_name} or you're not authorized to use it.", - message_type=MessageType.notice, - ) - ) - return - - # Otherwise show all available commands for the current role - available_commands = command_registry.get_commands_for_role(role) - - # Format help text based on role - if role == ConversationRole.COORDINATOR.value: - help_text = "## Assistant Commands (Coordinator Mode)\n\n" - else: - help_text = "## Assistant Commands (Team Mode)\n\n" - - # Group commands by category - project_commands = [] - whiteboard_commands = [] - request_commands = [] - team_commands = [] - status_commands = [] - info_commands = [] - - for name, cmd in available_commands.items(): - command_entry = f"- `/{name}`: {cmd['description']}" - - if "create-brief" in name or "add-goal" in name: - project_commands.append(command_entry) - elif "whiteboard" in name: - whiteboard_commands.append(command_entry) - elif "request" in name: - request_commands.append(command_entry) - elif "invite" in name or "join" in name or "list-participants" in name: - team_commands.append(command_entry) - elif "status" in name or "update" in name: - status_commands.append(command_entry) - else: - info_commands.append(command_entry) - - # Add sections to help text if they have commands - if project_commands: - help_text += "### Project Configuration\n" + "\n".join(project_commands) + "\n\n" - - if whiteboard_commands: - help_text += "### Whiteboard Management\n" + "\n".join(whiteboard_commands) + "\n\n" - - if team_commands: - help_text += "### Team Management\n" + "\n".join(team_commands) + "\n\n" - - if request_commands: - help_text += "### Information Request Management\n" + "\n".join(request_commands) + "\n\n" - - if status_commands: - help_text += "### Status Management\n" + "\n".join(status_commands) + "\n\n" - - if info_commands: - help_text += "### Information\n" + "\n".join(info_commands) + "\n\n" - - # Add role-specific guidance - if role == ConversationRole.COORDINATOR.value: - help_text += ( - "As a Coordinator, you are responsible for defining the project and responding to team member requests." - ) - else: - help_text += "As a Team member, you can access project information, request information, and report progress on project goals." - - await context.send_messages( - NewConversationMessage( - content=help_text, - message_type=MessageType.chat, - ) - ) - - -async def handle_create_brief_command( - context: ConversationContext, message: ConversationMessage, args: List[str] -) -> None: - """Handle the create-brief command.""" - # Parse the command - content = message.content.strip()[len("/create-brief") :].strip() - - if not content or "|" not in content: - await context.send_messages( - NewConversationMessage( - content="Please provide a brief title and description in the format: `/create-brief Title|Description here`", - message_type=MessageType.notice, - ) - ) - return - - # Extract title and description - try: - title, description = content.split("|", 1) - title = title.strip() - description = description.strip() - - if not title or not description: - raise ValueError("Both name and description are required") - - # Create the brief without sending a notification (we'll send our own) - briefing = await ProjectManager.update_project_brief(context, title, description, send_notification=False) - - if briefing: - await context.send_messages( - NewConversationMessage( - content=f"Brief '{title}' updated successfully.", - message_type=MessageType.chat, - ) - ) - else: - await context.send_messages( - NewConversationMessage( - content="Failed to update brief. Please try again.", - message_type=MessageType.notice, - ) - ) - except Exception as e: - logger.exception(f"Error updating brief: {e}") - await context.send_messages( - NewConversationMessage( - content=f"Error updating brief: {str(e)}", - message_type=MessageType.notice, - ) - ) - - -async def handle_add_goal_command(context: ConversationContext, message: ConversationMessage, args: List[str]) -> None: - """Handle the add-goal command.""" - # Parse the command - content = message.content.strip()[len("/add-goal") :].strip() - - if not content or "|" not in content: - await context.send_messages( - NewConversationMessage( - content="Please provide a goal name, description, and success criteria in the format: `/add-goal Goal Name|Goal description|Success criteria 1;Success criteria 2`", - message_type=MessageType.notice, - ) - ) - return - - # Extract goal details - try: - parts = content.split("|") - - if len(parts) < 2: - raise ValueError("Goal name and description are required") - - goal_name = parts[0].strip() - goal_description = parts[1].strip() - - # Parse success criteria if provided - success_criteria = [] - if len(parts) > 2 and parts[2].strip(): - criteria_list = parts[2].strip().split(";") - success_criteria = [c.strip() for c in criteria_list if c.strip()] - - if not goal_name or not goal_description: - raise ValueError("Both goal name and description are required") - - # Get project ID - project_id = await ConversationProjectManager.get_associated_project_id(context) - if not project_id: - await context.send_messages( - NewConversationMessage( - content="You are not associated with a project. Please create one first with `/create-brief`.", - message_type=MessageType.notice, - ) - ) - return - - # Use the dedicated method to add a goal to the project - goal = await ProjectManager.add_project_goal( - context=context, - goal_name=goal_name, - goal_description=goal_description, - success_criteria=success_criteria, - ) - - if goal: - # Notify all linked conversations about the update - await ProjectNotifier.notify_project_update( - context=context, - project_id=project_id, - update_type="briefing", - message=f"Goal added to project: {goal_name}", - ) - - # Build success criteria message - criteria_msg = "" - if success_criteria: - criteria_list = "\n".join([f"- {c}" for c in success_criteria]) - criteria_msg = f"\n\nSuccess Criteria:\n{criteria_list}" - - await context.send_messages( - NewConversationMessage( - content=f"Goal '{goal_name}' added successfully.{criteria_msg}", - message_type=MessageType.chat, - ) - ) - else: - await context.send_messages( - NewConversationMessage( - content="Failed to add new goal. Please try again.", - message_type=MessageType.notice, - ) - ) - except Exception as e: - logger.exception(f"Error adding goal: {e}") - await context.send_messages( - NewConversationMessage( - content=f"Error adding goal: {str(e)}", - message_type=MessageType.notice, - ) - ) - - -async def handle_request_info_command( - context: ConversationContext, message: ConversationMessage, args: List[str] -) -> None: - """Handle the request-info command.""" - # Parse the command - content = message.content.strip()[len("/request-info") :].strip() - - if not content or "|" not in content: - await context.send_messages( - NewConversationMessage( - content="Please provide a request title and description in the format: `/request-info Request Title|Description of what you need|priority` (priority is optional: low, medium, high, critical)", - message_type=MessageType.notice, - ) - ) - return - - # Extract request details - try: - parts = content.split("|") - - title = parts[0].strip() - description = parts[1].strip() if len(parts) > 1 else "" - priority_str = parts[2].strip().lower() if len(parts) > 2 else "medium" - - if not title or not description: - raise ValueError("Both request title and description are required") - - # Map priority string to enum - priority_map = { - "low": RequestPriority.LOW, - "medium": RequestPriority.MEDIUM, - "high": RequestPriority.HIGH, - "critical": RequestPriority.CRITICAL, - } - priority = priority_map.get(priority_str, RequestPriority.MEDIUM) - - # Create the information request - success, request = await ProjectManager.create_information_request( - context=context, title=title, description=description, priority=priority - ) - - if success and request: - await context.send_messages( - NewConversationMessage( - content=f"Information request '{title}' created successfully with {priority_str} priority. The Coordinator has been notified and will respond to your request.", - message_type=MessageType.chat, - ) - ) - else: - await context.send_messages( - NewConversationMessage( - content="Failed to create information request. Please try again.", - message_type=MessageType.notice, - ) - ) - except Exception as e: - logger.exception(f"Error creating information request: {e}") - await context.send_messages( - NewConversationMessage( - content=f"Error creating information request: {str(e)}", - message_type=MessageType.notice, - ) - ) - - -async def handle_update_status_command( - context: ConversationContext, message: ConversationMessage, args: List[str] -) -> None: - """Handle the update-status command.""" - # Parse the command - content = message.content.strip()[len("/update-status") :].strip() - - if not content: - await context.send_messages( - NewConversationMessage( - content="Please provide status information in the format: `/update-status status|progress|message` (e.g., `/update-status in_progress|75|Making good progress on objectives`)", - message_type=MessageType.notice, - ) - ) - return - - # Extract status details - try: - parts = content.split("|") - - status = parts[0].strip() if parts else None - progress_str = parts[1].strip() if len(parts) > 1 else None - status_message = parts[2].strip() if len(parts) > 2 else None - - # Convert progress to int if provided - progress = None - if progress_str: - try: - progress = int(progress_str) - # Ensure progress is between 0-100 - progress = max(0, min(100, progress)) - except ValueError: - progress = None - - # Update the project status - success, status_obj = await ProjectManager.update_project_state( - context=context, state=status, status_message=status_message - ) - - if success and status_obj: - # Format progress as percentage if available - progress_text = f" ({progress}% complete)" if progress is not None else "" - - await context.send_messages( - NewConversationMessage( - content=f"Project status updated to '{status}'{progress_text}. All project participants will see this update.", - message_type=MessageType.chat, - ) - ) - else: - await context.send_messages( - NewConversationMessage( - content="Failed to update project status. Please try again.", - message_type=MessageType.notice, - ) - ) - except Exception as e: - logger.exception(f"Error updating project status: {e}") - await context.send_messages( - NewConversationMessage( - content=f"Error updating project status: {str(e)}", - message_type=MessageType.notice, - ) - ) - - -async def handle_resolve_request_command( - context: ConversationContext, message: ConversationMessage, args: List[str] -) -> None: - """Handle the resolve-request command.""" - # Parse the command - content = message.content.strip()[len("/resolve-request") :].strip() - - if not content or "|" not in content: - await context.send_messages( - NewConversationMessage( - content="Please provide a request ID and resolution in the format: `/resolve-request request_id|Resolution information here`", - message_type=MessageType.notice, - ) - ) - return - - try: - # Extract request ID and resolution - request_id, resolution = content.split("|", 1) - request_id = request_id.strip() - resolution = resolution.strip() - - if not request_id or not resolution: - raise ValueError("Both request ID and resolution are required") - - # Show all information requests if the user doesn't know the ID - if request_id.lower() == "list": - await context.send_messages( - NewConversationMessage( - content="Here are the active information requests:", - message_type=MessageType.notice, - ) - ) - - # Get information requests - requests = await ProjectManager.get_information_requests(context) - - # Filter for active requests - active_requests = [r for r in requests if r.status != RequestStatus.RESOLVED] - - if active_requests: - request_list = ["## Active Information Requests\n"] - - for request in active_requests: - request_list.append(f"**ID**: `{request.request_id}`") - request_list.append(f"**Title**: {request.title}") - request_list.append(f"**Priority**: {request.priority.value}") - request_list.append(f"**Description**: {request.description}") - request_list.append("") - - await context.send_messages( - NewConversationMessage( - content="\n".join(request_list), - message_type=MessageType.chat, - ) - ) - else: - await context.send_messages( - NewConversationMessage( - content="No active information requests found.", - message_type=MessageType.notice, - ) - ) - return - - # Resolve the information request - success, info_request = await ProjectManager.resolve_information_request( - context=context, request_id=request_id, resolution=resolution - ) - - if success and info_request: - await context.send_messages( - NewConversationMessage( - content=f"Information request '{info_request.title}' has been resolved. The Team has been notified.", - message_type=MessageType.chat, - ) - ) - else: - await context.send_messages( - NewConversationMessage( - content="Failed to resolve the information request. Make sure the request ID is correct and the request is not already resolved.", - message_type=MessageType.notice, - ) - ) - - # Suggest listing all requests to help the user - await context.send_messages( - NewConversationMessage( - content="Use `/resolve-request list|` to view all information requests and their IDs.", - message_type=MessageType.notice, - ) - ) - except Exception as e: - logger.exception(f"Error resolving information request: {e}") - await context.send_messages( - NewConversationMessage( - content=f"Error resolving information request: {str(e)}", - message_type=MessageType.notice, - ) - ) - - -async def handle_project_info_command( - context: ConversationContext, message: ConversationMessage, args: List[str] -) -> None: - """Handle the project-info command.""" - # Parse the command - content = " ".join(args).strip().lower() - - try: - # Determine which information to show - info_type = content if content else "all" - - if info_type not in ["all", "brief", "whiteboard", "status", "requests"]: - await context.send_messages( - NewConversationMessage( - content="Please specify what information you want to see: `/project-info [brief|whiteboard|status|requests]`", - message_type=MessageType.notice, - ) - ) - return - - # Get the requested information - output = [] - - # Always show project ID at the top for easy access - project_id = await ProjectManager.get_project_id(context) - if project_id: - # Check if Coordinator or Team - role = await ProjectManager.get_project_role(context) - if role == ConversationRole.COORDINATOR: - # For Coordinator, make it prominent with instructions - output.append(f"## Project ID: `{project_id}`") - output.append(f"_Share this ID with team members so they can join using_ `/join {project_id}`\n") - else: - # For Team, just show the ID - output.append(f"## Project ID: `{project_id}`\n") - - # Get brief if requested - if info_type in ["all", "brief"]: - briefing = await ProjectManager.get_project_brief(context) - - if briefing: - # Format briefing information - output.append(f"## Brief: {briefing.title}") - output.append(f"\n{briefing.description}\n") - - # Get project to access goals - if project_id: - project = ProjectStorage.read_project(project_id) - if project and project.goals: - output.append("\n### Goals:\n") - - for i, goal in enumerate(project.goals): - # Count completed criteria - completed = sum(1 for c in goal.success_criteria if c.completed) - total = len(goal.success_criteria) - - output.append(f"{i + 1}. **{goal.name}** - {goal.description}") - - if goal.success_criteria: - output.append(f" Progress: {completed}/{total} criteria complete") - output.append(" Success Criteria:") - - for j, criterion in enumerate(goal.success_criteria): - status = "✅" if criterion.completed else "⬜" - output.append(f" {status} {criterion.description}") - - output.append("") - - # Get project whiteboard if requested - if info_type in ["all", "whiteboard"]: - whiteboard = await ProjectManager.get_project_whiteboard(context) - - if whiteboard and whiteboard.content: - output.append("\n## Project Whiteboard\n") - output.append(whiteboard.content) - output.append("") - - if whiteboard.is_auto_generated: - output.append("*This whiteboard content is automatically updated by the assistant.*") - else: - output.append("*This whiteboard content has been manually edited.*") - - output.append("") - elif info_type == "whiteboard": - output.append("\n## Project Whiteboard\n") - output.append( - "*No whiteboard content available yet. Content will be automatically generated as the project progresses.*" - ) - - # Get project status if requested - if info_type in ["all", "status"]: - project_info = await ProjectManager.get_project_info(context) - - if project_info: - output.append("\n## Project Status\n") - output.append(f"**Current Status**: {project_info.state.value}") - - if project_info.status_message: - output.append(f"**Status Message**: {project_info.status_message}") - - # Success criteria status can be calculated from the brief if needed later - elif info_type == "status": - output.append("\n## Project Status\n") - output.append("*No project status defined yet. Update status with `/update-status`.*") - - # Get information requests if requested - if info_type in ["all", "requests"]: - requests = await ProjectManager.get_information_requests(context) - - if requests: - output.append("\n## Information Requests\n") - - # Group requests by status - active_requests = [r for r in requests if r.status != RequestStatus.RESOLVED] - resolved_requests = [r for r in requests if r.status == RequestStatus.RESOLVED] - - if active_requests: - output.append("### Active Requests\n") - - for request in active_requests: - priority_marker = { - RequestPriority.LOW.value: "🔹", - RequestPriority.MEDIUM.value: "🔶", - RequestPriority.HIGH.value: "🔴", - RequestPriority.CRITICAL.value: "⚠️", - }.get(request.priority.value, "🔹") - - # Include request ID for easy reference when resolving - output.append(f"{priority_marker} **{request.title}** ({request.status.value})") - output.append(f" ID: `{request.request_id}`") - output.append(f" {request.description}") - - if request.updates: - last_update = request.updates[-1] - output.append(f" *Last update: {last_update.get('message', '')}*") - - output.append("") - - if resolved_requests and info_type == "requests": - output.append("### Resolved Requests\n") - - for request in resolved_requests[:5]: # Show only the 5 most recent - output.append(f"✅ **{request.title}** ({request.status.value})") - output.append(f" ID: `{request.request_id}`") - - if request.resolution: - output.append(f" Resolution: {request.resolution}") - - output.append("") - elif info_type == "requests": - output.append("\n## Information Requests\n") - output.append("*No information requests created yet. Request information with `/request-info`.*") - - # If no data was found for any category - if not output: - output.append("No project information found. Start by creating a brief with `/create-brief`.") - - # Send the formatted information - await context.send_messages( - NewConversationMessage( - content="\n".join(output), - message_type=MessageType.chat, - ) - ) - - except Exception as e: - logger.exception(f"Error displaying project info: {e}") - await context.send_messages( - NewConversationMessage( - content=f"Error displaying project information: {str(e)}", - message_type=MessageType.notice, - ) - ) - - -async def handle_list_participants_command( - context: ConversationContext, message: ConversationMessage, args: List[str] -) -> None: - """Handle the list-participants command.""" - try: - # Get project ID - project_id = await ConversationProjectManager.get_associated_project_id(context) - if not project_id: - await context.send_messages( - NewConversationMessage( - content="You are not associated with a project.", - message_type=MessageType.notice, - ) - ) - return - - # Get all linked conversations - linked_conversation_ids = await ConversationProjectManager.get_linked_conversations(context) - - if not linked_conversation_ids: - await context.send_messages( - NewConversationMessage( - content="No linked conversations found. Invite participants with the `/invite` command.", - message_type=MessageType.notice, - ) - ) - return - - # Get participant information for all linked conversations - output = ["## Project Participants\n"] - - # First add information about this conversation - participants = await context.get_participants() - - output.append("### Coordinator Team\n") - for participant in participants.participants: - if participant.id != context.assistant.id: - output.append(f"- {participant.name}") - - # In the simplified implementation, we don't have detail about the linked conversations - # For a more complete implementation, we would need to get information - # about each linked conversation - - # For now, just report that we have no other team members - output.append("\n*No team members yet. Invite team members with the `/invite` command.*") - - # Send the information - await context.send_messages( - NewConversationMessage( - content="\n".join(output), - message_type=MessageType.chat, - ) - ) - - except Exception as e: - logger.exception(f"Error listing participants: {e}") - await context.send_messages( - NewConversationMessage( - content=f"Error listing participants: {str(e)}", - message_type=MessageType.notice, - ) - ) - - -# File synchronization command handler -async def handle_sync_files_command( - context: ConversationContext, message: ConversationMessage, args: List[str] -) -> None: - """ - Handle the sync-files command which synchronizes shared files from Coordinator to Team. - - This is primarily for Team members to explicitly request a file sync - if they suspect files are out of sync or missing. - """ - try: - # Get project ID - project_id = await ProjectManager.get_project_id(context) - if not project_id: - await context.send_messages( - NewConversationMessage( - content="You are not associated with a project. Please join a project first.", - message_type=MessageType.notice, - ) - ) - return - - # Import the file manager - from .project_files import ProjectFileManager - - # Start sync with a simple message - await context.send_messages( - NewConversationMessage( - content="Synchronizing files from project...", - message_type=MessageType.notice, - ) - ) - - # Perform synchronization directly - this handles all error messaging - await ProjectFileManager.synchronize_files_to_team_conversation(context=context, project_id=project_id) - - except Exception as e: - logger.exception(f"Error synchronizing files: {e}") - await context.send_messages( - NewConversationMessage( - content=f"Error synchronizing files: {str(e)}", - message_type=MessageType.notice, - ) - ) - - -# General commands (available to all) -command_registry.register_command( - "help", - handle_help_command, - "Get help with available commands", - "/help [command]", - "/help project-info", - None, # Available to all roles -) - -command_registry.register_command( - "project-info", - handle_project_info_command, - "View project information", - "/project-info [brief|whiteboard|status|requests]", - "/project-info brief", - None, # Available to all roles -) - -# Team management commands -# Note: Manual project joining with /join is no longer needed - users just click the share URL - -command_registry.register_command( - "list-participants", - handle_list_participants_command, - "List all project participants", - "/list-participants", - "/list-participants", - ["coordinator"], # Only Coordinator can list participants -) - - -# Coordinator commands -command_registry.register_command( - "create-brief", - handle_create_brief_command, - "Create a brief", - "/create-brief Title|Description", - "/create-brief Website Redesign|We need to modernize our company website to improve user experience and conversions.", - ["coordinator"], # Only Coordinator can create briefs -) - -command_registry.register_command( - "add-goal", - handle_add_goal_command, - "Add a goal", - "/add-goal Goal Name|Goal description|Success criterion 1;Success criterion 2", - "/add-goal Redesign Homepage|Create a new responsive homepage|Design approved by stakeholders;Mobile compatibility verified", - ["coordinator"], # Only Coordinator can add goals -) - - -command_registry.register_command( - "resolve-request", - handle_resolve_request_command, - "Resolve an information request", - "/resolve-request request_id|Resolution information", - "/resolve-request abc123|The API documentation can be found at docs.example.com/api", - ["coordinator"], # Only Coordinator can resolve requests -) - -# Team commands -command_registry.register_command( - "request-info", - handle_request_info_command, - "Request information or assistance from the Coordinator", - "/request-info Request Title|Request description|priority", - "/request-info Need API Documentation|I need access to the API documentation for integration|high", - ["team"], # Only team can create requests -) - -command_registry.register_command( - "update-status", - handle_update_status_command, - "Update project status and progress", - "/update-status status|progress|message", - "/update-status in_progress|50|Completed homepage wireframes, working on mobile design", - ["team"], # Only team can update status -) - -# File synchronization command (primarily for team members) -command_registry.register_command( - "sync-files", - handle_sync_files_command, - "Synchronize shared files from the project to this conversation", - "/sync-files", - "/sync-files", - ["team"], # Primarily for team members -) diff --git a/assistants/project-assistant/assistant/common.py b/assistants/project-assistant/assistant/common.py new file mode 100644 index 000000000..71d566d47 --- /dev/null +++ b/assistants/project-assistant/assistant/common.py @@ -0,0 +1,41 @@ +""" +Common utilities shared between knowledge transfer assistant modes. + +This module provides shared functionality for team and coordinator conversation handlers, +helping to reduce code duplication and maintain consistency. +""" + +from semantic_workbench_assistant.assistant_app import ConversationContext + +from .data import LogEntryType +from .domain.share_manager import ShareManager + + +async def log_transfer_action( + context: ConversationContext, + entry_type: LogEntryType, + message: str, + related_entity_id: str | None = None, + additional_metadata: dict | None = None, +) -> None: + """ + Log an action to the knowledge transfer log. + + This utility function handles retrieving the share ID and logging the event + using the appropriate storage mechanism. It's used by both Coordinator and Team mode + handlers to maintain consistent logging. + + Args: + context: The conversation context + entry_type: Type of log entry + message: Human-readable description of the action + related_entity_id: Optional ID of a related entity (e.g., request ID) + additional_metadata: Optional additional metadata to include in the log + """ + await ShareManager.log_share_event( + context=context, + entry_type=entry_type.value, + message=message, + related_entity_id=related_entity_id, + metadata=additional_metadata, + ) diff --git a/assistants/project-assistant/assistant/config.py b/assistants/project-assistant/assistant/config.py index 47fbe28f1..507819b1e 100644 --- a/assistants/project-assistant/assistant/config.py +++ b/assistants/project-assistant/assistant/config.py @@ -14,7 +14,7 @@ class RequestConfig(BaseModel): model_config = ConfigDict( - title="Response Generation", + title="Response generation", json_schema_extra={ "required": ["max_tokens", "response_tokens", "openai_model"], }, @@ -23,7 +23,7 @@ class RequestConfig(BaseModel): max_tokens: Annotated[ int, Field( - title="Max Tokens", + title="Max tokens", description=( "The maximum number of tokens to use for both the prompt and response. Current max supported by OpenAI" " is 128k tokens, but varies by model (https://platform.openai.com/docs/models)" @@ -34,7 +34,7 @@ class RequestConfig(BaseModel): response_tokens: Annotated[ int, Field( - title="Response Tokens", + title="Response tokens", description=( "The number of tokens to use for the response, will reduce the number of tokens available for the" " prompt. Current max supported by OpenAI is 4096 tokens (https://platform.openai.com/docs/models)" @@ -45,82 +45,69 @@ class RequestConfig(BaseModel): coordinator_conversation_token_limit: Annotated[ int, Field( - title="Coordinator Conversation Token Limit", + title="Coordinator conversation token limit", description="The maximum number of tokens to use for the coordinator conversation history.", ), ] = 4000 openai_model: Annotated[ str, - Field(title="OpenAI Model", description="The OpenAI model to use for generating responses."), - ] = "gpt-4o" + Field( + title="OpenAI model", + description="The OpenAI model to use for generating responses.", + ), + ] = "gpt-4.1" class PromptConfig(BaseModel): model_config = ConfigDict( - title="Prompt Templates", + title="Prompt templates", json_schema_extra={ "required": [ - "coordinator_role", "coordinator_instructions", - "team_role", "team_instructions", - "whiteboard_prompt", - "project_information_request_detection", + "share_information_request_detection", + "update_knowledge_digest", + "welcome_message_generation", ], }, ) - coordinator_role: Annotated[ - str, - Field( - title="Coordinator Role", - description="The role of the coordinator assistant. This is added to the prompt when in coordinator mode.", - ), - UISchema(widget="textarea"), - ] = load_text_include("coordinator_role.txt") - coordinator_instructions: Annotated[ str, Field( - title="Coordinator Instructions", - description="The instructions to give the coordinator assistant. This is added to the prompt when in coordinator mode.", - ), - UISchema(widget="textarea"), - ] = load_text_include("coordinator_instructions.txt") - - team_role: Annotated[ - str, - Field( - title="Team Role", - description="The role of the team assistant. This is added to the prompt when in team member mode.", + title="Coordinator instructions", + description="The instructions to give the coordinator assistant. This is added to the prompt when in coordinator mode.", # noqa: E501 ), UISchema(widget="textarea"), - ] = load_text_include("team_role.txt") + ] = load_text_include("coordinator_instructions.md") team_instructions: Annotated[ str, Field( - title="Team Instructions", - description="The instructions to give the team assistant. This is added to the prompt when in team member mode.", + title="Team instructions", + description="The instructions to give the team assistant. This is added to the prompt when in team member mode.", # noqa: E501 ), UISchema(widget="textarea"), ] = load_text_include("team_instructions.txt") - project_information_request_detection: Annotated[ + detect_information_request_needs: Annotated[ str, Field( - title="Information Request Detection Prompt", - description="The prompt used to detect information requests in project assistant mode.", + title="Information Request detection prompt", + description="The prompt used to detect information requests in knowledge transfer mode.", ), UISchema(widget="textarea"), - ] = load_text_include("project_information_request_detection.txt") + ] = load_text_include("detect_information_request_needs.md") - whiteboard_prompt: Annotated[ + update_knowledge_digest: Annotated[ str, - Field(title="Whiteboard Prompt", description="The prompt used to generate whiteboard content."), + Field( + title="Knowledge Digest update prompt", + description="The prompt used to generate updated knowledge digest content.", + ), UISchema(widget="textarea"), - ] = load_text_include("whiteboard_prompt.txt") + ] = load_text_include("update_knowledge_digest.md") welcome_message_generation: Annotated[ str, @@ -134,9 +121,9 @@ class PromptConfig(BaseModel): class CoordinatorConfig(BaseModel): model_config = ConfigDict( - title="Coordinator Configuration", + title="Coordinator configuration", json_schema_extra={ - "required": ["welcome_message", "prompt_for_files"], + "required": ["welcome_message", "preferred_communication_style", "max_digest_tokens"], }, ) @@ -144,70 +131,79 @@ class CoordinatorConfig(BaseModel): str, Field( title="Coordinator Welcome Message", - description="The message to display when a coordinator starts a new project. {share_url} will be replaced with the actual URL.", + description="The message to display when a coordinator starts a new knowledge transfer. {share_url} will be replaced with the actual URL.", # noqa: E501 ), UISchema(widget="textarea"), - ] = """# Welcome to the Project Assistant + ] = """# Welcome to Knowledge Transfer -This conversation is your personal conversation as the project coordinator. +Welcome! I'm here to help you capture and share knowledge in a way that others can easily explore and understand. +Think of me as your personal knowledge bridge - I'll help you: -**To invite team members to your project, copy and share this link with them:** -[Join Team Conversation]({share_url}) +- 📚 Organize your thoughts - whether from documents, code, research papers, or brainstorming sessions +- 🔄 Establish shared understanding - I'll ask questions to ensure we're aligned on what matters most +- 🎯 Define learning objectives - so we can track progress and outcomes +- 🔍 Make your knowledge interactive - so others can explore the "why" behind decisions, alternatives considered, + and deeper context -I've created a brief for your project. Let's start by updating it with your project goals and details.""" +Simply share your content or ideas, tell me who needs to understand them, and what aspects you want to highlight. +I'll capture what knowledge you give me so it can be shared with your team members for them to explore at their own pace. - prompt_for_files: Annotated[ - str, +In the side panel, you can see your "knowledge brief". This brief will be shared with your team members and will +help them understand the content of your knowledge transfer. You can ask me to update it at any time. + +To get started, let's discuss your audience. Who are you going to be sharing your knowledge with?""" # noqa: E501 + + max_digest_tokens: Annotated[ + int, Field( - title="File Upload Prompt", - description="The message used to prompt project coordinators to upload relevant files.", + title="Maximum digest tokens", + description=("The number of tokens to use for the knowledge digest. Default: 4096"), ), - UISchema(widget="textarea"), - ] = "To begin building your project context, would you like to upload any relevant files like documents, images, or data that your team will need? You can drag and drop files directly into this conversation." + ] = 4_096 - list_participants_command: Annotated[ + preferred_communication_style: Annotated[ str, Field( - title="List Participants Command", - description="The command project coordinators can use to list all participants (without the slash).", + title="Preferred communication style", + description="The preferred communication style for the assistant. This is used to tailor responses.", ), - ] = "list-participants" + UISchema(widget="textarea"), + ] = "Speak plainly. Keep your responses short and concise to create a more collaborative dynamic. Use no filler words or unnecessary content." # noqa: E501 class TeamConfig(BaseModel): model_config = ConfigDict( - title="Team Member Configuration", + title="Team-member configuration", json_schema_extra={ - "required": ["default_welcome_message", "status_command"], + "required": ["default_welcome_message", "preferred_communication_style"], }, ) default_welcome_message: Annotated[ str, Field( - title="Team Welcome Message", - description="The message to display when a user joins a project as a Team member. Shown after successfully joining a project.", + title="Team Welcome message", + description="The message to display when a user joins a knowledge transfer as a Team member. Shown after successfully joining a knowledge transfer.", # noqa: E501 ), UISchema(widget="textarea"), - ] = "# Welcome to Your Team Conversation\n\nYou've joined this project as a team member. This is your personal conversation for working on the project. You can communicate with the assistant, make information requests, and track your progress here." + ] = "# Welcome to Your Team Conversation\n\nYou've joined as a team member. This is your personal conversation for exploring the knowledge share. You can communicate with the assistant, make information requests, and track your progress here." # noqa: E501 - status_command: Annotated[ + preferred_communication_style: Annotated[ str, Field( - title="Status Command", - description="The command project participants can use to check project status (without the slash).", + title="Preferred communication style", + description="The preferred communication style for the assistant. This is used to tailor responses.", ), - ] = "project-status" + UISchema(widget="textarea"), + ] = "Speak plainly. Keep your responses short and concise to create a more collaborative dynamic. Use no filler words or unnecessary content. Users tend to not want to read long answers and will skip over text. Let the user ask for longer information as needed." # noqa: E501 # Base Assistant Configuration - shared by all templates class AssistantConfigModel(BaseModel): - project_or_context: Annotated[str, UISchema(widget="hidden")] = "project" - Project_or_Context: Annotated[str, UISchema(widget="hidden")] = "Project" enable_debug_output: Annotated[ bool, Field( - title="Include Debug Output", + title="Include debug output", description="Include debug output on conversation messages.", ), ] = False @@ -215,7 +211,7 @@ class AssistantConfigModel(BaseModel): prompt_config: Annotated[ PromptConfig, Field( - title="Prompt Configuration", + title="Prompt configuration", description="Configuration for prompt templates used throughout the assistant.", ), ] = PromptConfig() @@ -223,7 +219,7 @@ class AssistantConfigModel(BaseModel): request_config: Annotated[ RequestConfig, Field( - title="Request Configuration", + title="Request configuration", ), ] = RequestConfig() @@ -232,14 +228,14 @@ class AssistantConfigModel(BaseModel): content_safety_config: Annotated[ CombinedContentSafetyEvaluatorConfig, Field( - title="Content Safety Configuration", + title="Content Safety configuration", ), ] = CombinedContentSafetyEvaluatorConfig() attachments_config: Annotated[ AttachmentsConfigModel, Field( - title="Attachments Configuration", + title="Attachments configuration", description="Configuration for handling file attachments in messages.", ), ] = AttachmentsConfigModel() @@ -248,47 +244,26 @@ class AssistantConfigModel(BaseModel): auto_sync_files: Annotated[ bool, Field( - title="Auto-sync Files", + title="Auto-sync files", description="Automatically synchronize files between linked conversations.", ), ] = True - track_progress: Annotated[ - bool, - Field( - title="Track Progress", - description="Track project progress with goals, criteria completion, and overall project state.", - ), - ] = True - - proactive_guidance: Annotated[ - bool, - Field( - title="Proactive Guidance", - description="Proactively guide project coordinators through context building.", - ), - ] = True - coordinator_config: Annotated[ CoordinatorConfig, Field( - title="Coordinator Configuration", - description="Configuration for project coordinators.", + title="Coordinator configuration", + description="Configuration for knowledge transfer coordinators.", ), ] = CoordinatorConfig() team_config: Annotated[ TeamConfig, Field( - title="Team Configuration", - description="Configuration for project team members.", + title="Team configuration", + description="Configuration for knowledge transfer team members.", ), ] = TeamConfig() -assistant_config = BaseModelAssistantConfig( - AssistantConfigModel, - additional_templates={ - "knowledge_transfer": AssistantConfigModel, - }, -) +assistant_config = BaseModelAssistantConfig(AssistantConfigModel) diff --git a/assistants/project-assistant/assistant/conversation_clients.py b/assistants/project-assistant/assistant/conversation_clients.py index d945811f0..846caaf03 100644 --- a/assistants/project-assistant/assistant/conversation_clients.py +++ b/assistants/project-assistant/assistant/conversation_clients.py @@ -1,21 +1,20 @@ """ Project assistant functionality for cross-conversation communication. -This module handles the project assistant's core functionality for managing +This module handles the knowledge transfer assistant's core functionality for managing communication between conversations. It provides utilities for creating temporary contexts and accessing other conversations. """ -from typing import Any, Optional, Tuple +from typing import Any from semantic_workbench_api_model.workbench_service_client import ConversationAPIClient from semantic_workbench_assistant.assistant_app import ConversationContext from semantic_workbench_assistant.storage import read_model -from .conversation_project_link import ConversationProjectManager +from .data import ConversationRole, ConversationShareInfo from .logging import logger -from .project_storage import ProjectStorageManager -from .project_storage_models import ConversationRole +from .storage import ShareStorageManager class ConversationClientManager: @@ -23,7 +22,7 @@ class ConversationClientManager: Manages API clients for accessing other conversations. This utility class provides methods for creating API clients and temporary contexts - that can be used to interact with other conversations in the same project. + that can be used to interact with other conversations in the same knowledge transfer. """ @staticmethod @@ -34,14 +33,14 @@ def get_conversation_client(context: ConversationContext, conversation_id: str) return context.for_conversation(conversation_id)._conversation_client @staticmethod - async def get_coordinator_client_for_project( - context: ConversationContext, project_id: str - ) -> Tuple[Optional[Any], Optional[str]]: + async def get_coordinator_client_for_share( + context: ConversationContext, share_id: str + ) -> tuple[Any | None, str | None]: """ - Gets a client for accessing the Coordinator conversation for a project. + Gets a client for accessing the Coordinator conversation for a knowledge transfer. """ # Look for the Coordinator conversation directory - coordinator_dir = ProjectStorageManager.get_project_dir(project_id) / ConversationRole.COORDINATOR + coordinator_dir = ShareStorageManager.get_share_dir(share_id) / ConversationRole.COORDINATOR if not coordinator_dir.exists(): return None, None @@ -53,7 +52,7 @@ async def get_coordinator_client_for_project( return None, None # Read the role information to get the Coordinator conversation ID - role_data = read_model(role_file, ConversationProjectManager.ConversationRoleInfo) + role_data = read_model(role_file, ConversationShareInfo) if not role_data or not role_data.conversation_id: return None, None @@ -71,7 +70,7 @@ async def get_coordinator_client_for_project( @staticmethod async def create_temporary_context_for_conversation( source_context: ConversationContext, target_conversation_id: str - ) -> Optional[ConversationContext]: + ) -> ConversationContext | None: """ Creates a temporary context for the target conversation ID. """ diff --git a/assistants/project-assistant/assistant/conversation_project_link.py b/assistants/project-assistant/assistant/conversation_project_link.py deleted file mode 100644 index 8178d2f6e..000000000 --- a/assistants/project-assistant/assistant/conversation_project_link.py +++ /dev/null @@ -1,131 +0,0 @@ -""" -Manages associations between conversations and projects. - -This module handles the linking of conversations to projects, -defining roles (Coordinator/Team) and maintaining relationships. -""" - -from typing import List, Optional - -from pydantic import BaseModel -from semantic_workbench_assistant.assistant_app import ConversationContext -from semantic_workbench_assistant.storage import read_model, write_model - -from .logging import logger -from .project_storage import ProjectStorageManager -from .project_storage_models import ConversationRole - - -class ConversationProjectManager: - """Manages the association between conversations and projects.""" - - class ConversationRoleInfo(BaseModel): - """Stores a conversation's role in a project.""" - - project_id: str - role: ConversationRole - conversation_id: str - - class ProjectAssociation(BaseModel): - """Stores a conversation's project association.""" - - project_id: str - - @staticmethod - async def get_linked_conversations(context: ConversationContext) -> List[str]: - """ - Gets all conversations linked to this one through the same project. - """ - try: - # Get project ID - project_id = await ConversationProjectManager.get_associated_project_id(context) - if not project_id: - return [] - - # Get the linked conversations directory - linked_dir = ProjectStorageManager.get_linked_conversations_dir(project_id) - if not linked_dir.exists(): - return [] - - # Get all conversation files in the directory - result = [] - conversation_id = str(context.id) - - # Each file in the directory represents a linked conversation - # The filename itself is the conversation ID - for file_path in linked_dir.glob("*"): - if file_path.is_file(): - # The filename is the conversation ID - conv_id = file_path.name - if conv_id != conversation_id: - result.append(conv_id) - - return result - - except Exception as e: - logger.error(f"Error getting linked conversations: {e}") - return [] - - @staticmethod - async def set_conversation_role(context: ConversationContext, project_id: str, role: ConversationRole) -> None: - """ - Sets the role of a conversation in a project. - """ - role_data = ConversationProjectManager.ConversationRoleInfo( - project_id=project_id, role=role, conversation_id=str(context.id) - ) - role_path = ProjectStorageManager.get_conversation_role_file_path(context) - write_model(role_path, role_data) - - @staticmethod - async def get_conversation_role(context: ConversationContext) -> Optional[ConversationRole]: - """ - Gets the role of a conversation in a project. - """ - role_path = ProjectStorageManager.get_conversation_role_file_path(context) - role_data = read_model(role_path, ConversationProjectManager.ConversationRoleInfo) - - if role_data: - return role_data.role - - return None - - @staticmethod - async def associate_conversation_with_project(context: ConversationContext, project_id: str) -> None: - """ - Associates a conversation with a project. - """ - logger.debug(f"Associating conversation {context.id} with project {project_id}") - - try: - # 1. Store the project association in the conversation's storage directory - project_data = ConversationProjectManager.ProjectAssociation(project_id=project_id) - project_path = ProjectStorageManager.get_conversation_project_file_path(context) - logger.debug(f"Writing project association to {project_path}") - write_model(project_path, project_data) - - # 2. Register this conversation in the project's linked_conversations directory - linked_dir = ProjectStorageManager.get_linked_conversations_dir(project_id) - logger.debug(f"Registering in linked_conversations directory: {linked_dir}") - conversation_file = linked_dir / str(context.id) - - # Touch the file to create it if it doesn't exist - # We don't need to write any content to it, just its existence is sufficient - conversation_file.touch(exist_ok=True) - logger.debug(f"Created conversation link file: {conversation_file}") - except Exception as e: - logger.error(f"Error associating conversation with project: {e}") - raise - - @staticmethod - async def get_associated_project_id(context: ConversationContext) -> Optional[str]: - """ - Gets the project ID associated with a conversation. - """ - project_path = ProjectStorageManager.get_conversation_project_file_path(context) - project_data = read_model(project_path, ConversationProjectManager.ProjectAssociation) - - if project_data: - return project_data.project_id - - return None diff --git a/assistants/project-assistant/assistant/data.py b/assistants/project-assistant/assistant/data.py new file mode 100644 index 000000000..8b2b6c4db --- /dev/null +++ b/assistants/project-assistant/assistant/data.py @@ -0,0 +1,274 @@ +""" +Data models for knowledge transfer share entities (briefs, information requests, logs, etc.) + +This module provides the core data structures for the knowledge transfer assistant, +without any artifact abstraction or unnecessary complexity. +""" + +import uuid +from datetime import UTC, datetime +from enum import Enum +from typing import Any + +from pydantic import BaseModel, Field + + +class ConversationRole(str, Enum): + COORDINATOR = "coordinator" + TEAM = "team" + SHAREABLE_TEMPLATE = "shareable_template" + + +class ConversationShareInfo(BaseModel): + share_id: str + conversation_id: str + role: ConversationRole + + +class ConversationPreferences(BaseModel): + """ + Preferences for a conversation, such as preferred communication style. + This is used to store user preferences for how they want to interact with the assistant. + """ + + preferred_communication_style: str | None = None + + +class InspectorTab(str, Enum): + BRIEF = "brief" + LEARNING = "learning" + SHARING = "sharing" + DEBUG = "debug" + + +class RequestPriority(str, Enum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + + +class RequestStatus(str, Enum): + NEW = "new" + ACKNOWLEDGED = "acknowledged" + IN_PROGRESS = "in_progress" + RESOLVED = "resolved" + DEFERRED = "deferred" + + +class BaseEntity(BaseModel): + version: int = 1 + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + created_by: str + updated_by: str + conversation_id: str + + +class LearningOutcomeAchievement(BaseModel): + outcome_id: str + achieved: bool = True + achieved_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + + +class TeamConversationInfo(BaseModel): + conversation_id: str + redeemer_user_id: str + redeemer_name: str + joined_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + last_active_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + outcome_achievements: list[LearningOutcomeAchievement] = Field(default_factory=list) + + +class LearningOutcome(BaseModel): + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + description: str + + +class LearningObjective(BaseModel): + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + name: str + description: str + priority: int = 1 + learning_outcomes: list[LearningOutcome] = Field(default_factory=list) + + +class KnowledgeBrief(BaseEntity): + title: str + content: str + timeline: str | None = None + + +class KnowledgeDigest(BaseEntity): + content: str = "" + is_auto_generated: bool = True + + +class InformationRequestSource(str, Enum): + INTERNAL = "internal" # Some subsystem or tool added this request. + TEAM = "team" + + +class InformationRequest(BaseEntity): + request_id: str = Field(default_factory=lambda: str(uuid.uuid4())) + source: InformationRequestSource = InformationRequestSource.INTERNAL + title: str + description: str + priority: RequestPriority = RequestPriority.MEDIUM + status: RequestStatus = RequestStatus.NEW + related_objective_ids: list[str] = Field(default_factory=list) + resolution: str | None = None + resolved_at: datetime | None = None + resolved_by: str | None = None + + # History of status updates and comments + updates: list[dict[str, Any]] = Field(default_factory=list) + + +class NewInformationRequest(BaseModel): + """ + Represents a new information request to be created. + This is used to capture requests for additional information. + """ + + title: str + description: str + priority: RequestPriority + + class Config: + extra = "forbid" + + +class LogEntryType(str, Enum): + # Brief-related events + BRIEFING_CREATED = "briefing_created" + BRIEFING_UPDATED = "briefing_updated" + + # Learning Objective-related events + LEARNING_OBJECTIVE_ADDED = "learning_objective_added" + LEARNING_OBJECTIVE_DELETED = "learning_objective_deleted" + LEARNING_OBJECTIVE_UPDATED = "learning_objective_updated" + + # Information request lifecycle events + REQUEST_CREATED = "request_created" + REQUEST_UPDATED = "request_updated" + REQUEST_DELETED = "request_deleted" + + # Project state and progress events + STATUS_CHANGED = "status_changed" + OUTCOME_ATTAINED = "outcome_attained" + REQUEST_RESOLVED = "request_resolved" + LEARNING_OBJECTIVE_ACCOMPLISHED = "learning_objective_accomplished" + + # Participant events + PARTICIPANT_JOINED = "participant_joined" + PARTICIPANT_LEFT = "participant_left" + + # Share lifecycle events + SHARE_STARTED = "share_started" + SHARE_COMPLETED = "share_completed" + SHARE_ABORTED = "share_aborted" + + # Miscellaneous events + SHARE_INFORMATION_UPDATE = "share_information_update" + FILE_SHARED = "file_shared" + FILE_DELETED = "file_deleted" + KNOWLEDGE_DIGEST_UPDATE = "knowledge_digest_update" + CUSTOM = "custom" + + +class LogEntry(BaseModel): + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC)) + entry_type: LogEntryType + user_id: str + user_name: str + related_entity_id: str | None = None + entity_type: str | None = None + message: str + metadata: dict | None = None + + +class ShareLog(BaseModel): + entries: list[LogEntry] = Field(default_factory=list) # Chronological list of log entries + + +class TaskStatus(str, Enum): + """ + Enum for the status of a task. + """ + + PENDING = "pending" # Task not yet started + IN_PROGRESS = "in_progress" # Currently working on (limit to ONE task at a time) + COMPLETED = "completed" # Task finished successfully + CANCELLED = "cancelled" # Task no longer needed + + +class TaskPriority(str, Enum): + """ + Enum for the priority of a task. + """ + + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + + +class NewTaskInfo(BaseModel): + """ + A class to represent a new task to be added. + """ + + content: str # Description of the task + priority: TaskPriority = TaskPriority.MEDIUM # Default priority is 'medium' + status: TaskStatus = TaskStatus.PENDING # Default status is 'pending' + + +class TaskInfo(BaseModel): + task_id: str + content: str + status: TaskStatus + priority: TaskPriority + + +class Share(BaseModel): + share_id: str + coordinator_conversation_id: str | None = None + shared_conversation_id: str | None = None + team_conversations: dict[str, TeamConversationInfo] = Field(default_factory=dict) + share_url: str | None = None + version: int = 1 + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + updated_by: str | None = None + + # Knowledge Package components + audience: str | None = None + audience_takeaways: list[str] = Field(default_factory=list) + learning_objectives: list[LearningObjective] = Field(default_factory=list) + brief: KnowledgeBrief | None + preferred_communication_style: str | None = None + digest: KnowledgeDigest | None + + # Lifecycle + is_intended_to_accomplish_outcomes: bool = True + next_learning_actions: list[str] = Field(default_factory=list) + # knowledge_organized: bool = False + requests: list[InformationRequest] = Field(default_factory=list) + tasks: list[TaskInfo] = Field(default_factory=list) + + log: ShareLog | None = Field(default_factory=lambda: ShareLog()) + + +class CoordinatorConversationMessage(BaseModel): + message_id: str + content: str + sender_name: str + timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC)) + is_assistant: bool = False + + +class CoordinatorConversationMessages(BaseModel): + knowledge_share_id: str + last_updated: datetime = Field(default_factory=lambda: datetime.now(UTC)) + messages: list[CoordinatorConversationMessage] = Field(default_factory=list) diff --git a/assistants/project-assistant/assistant/domain/__init__.py b/assistants/project-assistant/assistant/domain/__init__.py new file mode 100644 index 000000000..73d924834 --- /dev/null +++ b/assistants/project-assistant/assistant/domain/__init__.py @@ -0,0 +1,25 @@ +""" +Manager directory for Knowledge Transfer Assistant. + +This module provides the main KnowledgeTransferManager class for project management. +""" + +from .audience_manager import AudienceManager +from .information_request_manager import InformationRequestManager +from .knowledge_brief_manager import KnowledgeBriefManager +from .knowledge_digest_manager import KnowledgeDigestManager +from .learning_objectives_manager import LearningObjectivesManager +from .share_manager import ShareManager +from .tasks_manager import TasksManager +from .transfer_manager import TransferManager + +__all__ = [ + "AudienceManager", + "InformationRequestManager", + "KnowledgeBriefManager", + "KnowledgeDigestManager", + "LearningObjectivesManager", + "ShareManager", + "TasksManager", + "TransferManager", +] diff --git a/assistants/project-assistant/assistant/domain/audience_manager.py b/assistants/project-assistant/assistant/domain/audience_manager.py new file mode 100644 index 000000000..560c0c9e2 --- /dev/null +++ b/assistants/project-assistant/assistant/domain/audience_manager.py @@ -0,0 +1,59 @@ +""" +Knowledge transfer lifecycle management for Knowledge Transfer Assistant. + +Handles knowledge transfer state updates, completion, and lifecycle operations. +""" + +from datetime import UTC, datetime + +from semantic_workbench_assistant.assistant_app import ConversationContext + +from assistant.data import LogEntryType + +from .share_manager import ShareManager + + +class AudienceManager: + @staticmethod + async def update_audience( + context: ConversationContext, + audience_description: str, + ) -> None: + """ + Update the target audience description for a knowledge share. + """ + share = await ShareManager.get_share(context) + share.audience = audience_description.strip() + share.updated_at = datetime.now(UTC) + await ShareManager.set_share(context, share) + + await ShareManager.log_share_event( + context=context, + entry_type=LogEntryType.STATUS_CHANGED.value, + message=f"Updated target audience: {audience_description}", + metadata={ + "audience": audience_description, + }, + ) + + @staticmethod + async def update_audience_takeaways( + context: ConversationContext, + takeaways: list[str], + ) -> None: + """ + Update the key takeaways for the target audience. + """ + share = await ShareManager.get_share(context) + share.audience_takeaways = takeaways + share.updated_at = datetime.now(UTC) + await ShareManager.set_share(context, share) + + await ShareManager.log_share_event( + context=context, + entry_type=LogEntryType.STATUS_CHANGED.value, + message=f"Updated audience takeaways: {takeaways}", + metadata={ + "takeaways": takeaways, + }, + ) diff --git a/assistants/project-assistant/assistant/domain/conversation_preferences_manager.py b/assistants/project-assistant/assistant/domain/conversation_preferences_manager.py new file mode 100644 index 000000000..3a28424dd --- /dev/null +++ b/assistants/project-assistant/assistant/domain/conversation_preferences_manager.py @@ -0,0 +1,54 @@ +from semantic_workbench_assistant.assistant_app import ConversationContext + +from assistant.config import assistant_config +from assistant.data import ConversationPreferences, ConversationRole, InspectorTab +from assistant.domain.share_manager import ShareManager +from assistant.notifications import Notifications +from assistant.storage import ConversationStorage + + +class ConversationPreferencesManager: + @staticmethod + async def update_preferred_communication_style( + context: ConversationContext, + preferred_communication_style: str, + ) -> None: + """ + Update the preferences for a conversation. + """ + preferences = ConversationStorage.read_conversation_preferences(context) + + # Set the default preferences based on the assistant config. + if not preferences: + config = await assistant_config.get(context.assistant) + role = await ShareManager.get_conversation_role(context) + if role == ConversationRole.COORDINATOR: + style = config.coordinator_config.preferred_communication_style + else: + style = config.team_config.preferred_communication_style + preferences = ConversationPreferences( + preferred_communication_style=style, + ) + + preferences.preferred_communication_style = preferred_communication_style.strip() + ConversationStorage.write_conversation_preferences(context, preferences) + + await Notifications.notify(context, "Preferred communication style updated.") + await Notifications.notify_all_state_update(context, [InspectorTab.DEBUG]) + + @staticmethod + async def get_preferred_communication_style(context: ConversationContext) -> str: + """ + Get the preferred communication style for a conversation. + """ + preferences = ConversationStorage.read_conversation_preferences(context) + if preferences and preferences.preferred_communication_style: + return preferences.preferred_communication_style + + # Return the default from the assistant config if not set. + config = await assistant_config.get(context.assistant) + role = await ShareManager.get_conversation_role(context) + if role == ConversationRole.COORDINATOR: + return config.coordinator_config.preferred_communication_style + else: + return config.team_config.preferred_communication_style diff --git a/assistants/project-assistant/assistant/domain/information_request_manager.py b/assistants/project-assistant/assistant/domain/information_request_manager.py new file mode 100644 index 000000000..7234b73c5 --- /dev/null +++ b/assistants/project-assistant/assistant/domain/information_request_manager.py @@ -0,0 +1,212 @@ +from datetime import UTC, datetime + +from semantic_workbench_assistant.assistant_app import ConversationContext + +from assistant.data import ( + InformationRequest, + InformationRequestSource, + InspectorTab, + LogEntryType, + RequestPriority, + RequestStatus, +) +from assistant.logging import logger +from assistant.notifications import Notifications +from assistant.storage import ShareStorage +from assistant.utils import get_current_user_id + +from .share_manager import ShareManager + + +class InformationRequestManager: + """Manages information request operations.""" + + @staticmethod + async def get_information_requests( + context: ConversationContext, + ) -> list[InformationRequest]: + """Gets all information requests for the current conversation's share.""" + share_id = await ShareManager.get_share_id(context) + return ShareStorage.get_all_information_requests(share_id) + + @staticmethod + async def create_information_request( + context: ConversationContext, + title: str, + description: str, + priority: RequestPriority = RequestPriority.MEDIUM, + related_objective_ids: list[str] | None = None, + source: InformationRequestSource = InformationRequestSource.INTERNAL, + ) -> InformationRequest: + share_id = await ShareManager.get_share_id(context) + current_user_id = await get_current_user_id(context) + + information_request = InformationRequest( + title=title, + description=description, + priority=priority, + related_objective_ids=related_objective_ids or [], + created_by=current_user_id, + updated_by=current_user_id, + conversation_id=str(context.id), + source=source, + ) + + ShareStorage.write_information_request(share_id, information_request) + + await ShareManager.log_share_event( + context=context, + entry_type=LogEntryType.REQUEST_CREATED.value, + message=f"Created information request: {title}", + related_entity_id=information_request.request_id, + metadata={ + "priority": priority.value, + "request_id": information_request.request_id, + }, + ) + + await Notifications.notify_self_and_other(context, share_id, f"Information request '{title}' was created") + await Notifications.notify_all_state_update(context, [InspectorTab.SHARING]) + + return information_request + + @staticmethod + async def resolve_information_request( + context: ConversationContext, + request_id: str, + resolution: str, + ) -> InformationRequest: + share_id = await ShareManager.get_share_id(context) + current_user_id = await get_current_user_id(context) + + information_request = ShareStorage.read_information_request(share_id, request_id) + if not information_request: + # Try to find it in all requests + all_requests = ShareStorage.get_all_information_requests(share_id) + for request in all_requests: + if request.request_id == request_id: + information_request = request + break + + if not information_request: + raise ValueError(f"Information request {request_id} not found") + + # Check if already resolved + if information_request.status == RequestStatus.RESOLVED: + logger.warning(f"Information request {request_id} is already resolved") + return information_request + + # Update the request + information_request.status = RequestStatus.RESOLVED + information_request.resolution = resolution + information_request.resolved_at = datetime.now(UTC) + information_request.resolved_by = current_user_id + + # Add to history + information_request.updates.append({ + "timestamp": datetime.now(UTC).isoformat(), + "user_id": current_user_id, + "message": f"Request resolved: {resolution}", + "status": RequestStatus.RESOLVED.value, + }) + + # Update metadata + information_request.updated_at = datetime.now(UTC) + information_request.updated_by = current_user_id + information_request.version += 1 + + # Save the updated request + ShareStorage.write_information_request(share_id, information_request) + + # Log the resolution + await ShareManager.log_share_event( + context=context, + entry_type=LogEntryType.REQUEST_RESOLVED.value, + message=f"Resolved information request: {information_request.title}", + related_entity_id=information_request.request_id, + metadata={ + "resolution": resolution, + "request_title": information_request.title, + "request_priority": information_request.priority.value + if hasattr(information_request.priority, "value") + else information_request.priority, + }, + ) + + await Notifications.notify_all_state_update(context, [InspectorTab.SHARING]) + await Notifications.notify_self_and_other( + context, + share_id, + f"Information request '{information_request.title}' has been resolved: {resolution}", + information_request.conversation_id if information_request.conversation_id != str(context.id) else None, + ) + + return information_request + + @staticmethod + async def delete_information_request( + context: ConversationContext, + request_id: str, + ) -> str: + """ + Delete an information request. + + Args: + context: Current conversation context + request_id: ID of the request to delete + + Returns: + Success message + """ + share_id = await ShareManager.get_share_id(context) + current_user_id = await get_current_user_id(context) + + # Get information request by ID + cleaned_request_id = request_id.strip().replace('"', "").replace("'", "") + information_request = ShareStorage.read_information_request(share_id, cleaned_request_id) + if not information_request: + raise ValueError(f"Information request with ID '{request_id}' not found") + + # Check ownership - only allow deletion by the creator + if information_request.conversation_id != str(context.id): + raise PermissionError("You can only delete information requests that you created") + + # Get user info for logging + participants = await context.get_participants() + current_username = "Team Member" + for participant in participants.participants: + if participant.role == "user": + current_username = participant.name + break + + request_title = information_request.title + actual_request_id = information_request.request_id + + # Log the deletion + await ShareManager.log_share_event( + context=context, + entry_type=LogEntryType.REQUEST_DELETED.value, + message=f"Information request '{request_title}' was deleted by {current_username}", + related_entity_id=actual_request_id, + metadata={ + "request_title": request_title, + "deleted_by": current_user_id, + "deleted_by_name": current_username, + }, + ) + + # Delete the information request from the main share data + share = await ShareManager.get_share(context) + if share and share.requests: + share.requests = [req for req in share.requests if req.request_id != actual_request_id] + await ShareManager.set_share(context, share) + + # Notify about the deletion + await Notifications.notify_self_and_other( + context, + share_id, + f"Information request '{request_title}' has been deleted.", + ) + await Notifications.notify_all_state_update(context, [InspectorTab.SHARING]) + + return f"Information request '{request_title}' has been successfully deleted." diff --git a/assistants/project-assistant/assistant/domain/knowledge_brief_manager.py b/assistants/project-assistant/assistant/domain/knowledge_brief_manager.py new file mode 100644 index 000000000..75aa2959b --- /dev/null +++ b/assistants/project-assistant/assistant/domain/knowledge_brief_manager.py @@ -0,0 +1,62 @@ +from semantic_workbench_assistant.assistant_app import ConversationContext + +from assistant.data import InspectorTab, KnowledgeBrief, LogEntryType +from assistant.notifications import Notifications +from assistant.storage import ShareStorage +from assistant.utils import get_current_user_id + +from .share_manager import ShareManager + + +class KnowledgeBriefManager: + """Manages knowledge brief operations.""" + + @staticmethod + async def get_knowledge_brief( + context: ConversationContext, + ) -> KnowledgeBrief | None: + share_id = await ShareManager.get_share_id(context) + return ShareStorage.read_knowledge_brief(share_id) + + @staticmethod + async def update_knowledge_brief( + context: ConversationContext, + title: str, + content: str, + timeline: str | None = None, + ) -> KnowledgeBrief: + share_id = await ShareManager.get_share_id(context) + current_user_id = await get_current_user_id(context) + + brief = KnowledgeBrief( + title=title, + content=content, + timeline=timeline, + created_by=current_user_id, + updated_by=current_user_id, + conversation_id=str(context.id), + ) + + ShareStorage.write_knowledge_brief(share_id, brief) + + # Check if this is a creation or an update + existing_brief = ShareStorage.read_knowledge_brief(share_id) + if existing_brief: + # This is an update + await ShareManager.log_share_event( + context=context, + entry_type=LogEntryType.BRIEFING_UPDATED.value, + message=f"Updated brief: {title}", + ) + else: + # This is a creation + await ShareManager.log_share_event( + context=context, + entry_type=LogEntryType.BRIEFING_CREATED.value, + message=f"Created brief: {title}", + ) + + await Notifications.notify_all(context, share_id, "Knowledge brief has been updated", {"content": content}) + await Notifications.notify_all_state_update(context, [InspectorTab.BRIEF]) + + return brief diff --git a/assistants/project-assistant/assistant/domain/knowledge_digest_manager.py b/assistants/project-assistant/assistant/domain/knowledge_digest_manager.py new file mode 100644 index 000000000..45ece3b67 --- /dev/null +++ b/assistants/project-assistant/assistant/domain/knowledge_digest_manager.py @@ -0,0 +1,69 @@ +from datetime import UTC, datetime + +from semantic_workbench_assistant.assistant_app import ConversationContext + +from assistant.data import InspectorTab, KnowledgeDigest, LogEntryType +from assistant.notifications import Notifications +from assistant.storage import ShareStorage +from assistant.utils import get_current_user_id + +from .share_manager import ShareManager + + +class KnowledgeDigestManager: + """Manages knowledge digest operations.""" + + @staticmethod + async def get_knowledge_digest( + context: ConversationContext, + ) -> KnowledgeDigest | None: + share_id = await ShareManager.get_share_id(context) + if not share_id: + return None + return ShareStorage.read_knowledge_digest(share_id) + + @staticmethod + async def update_knowledge_digest( + context: ConversationContext, + content: str, + is_auto_generated: bool = True, + ) -> None: + share_id = await ShareManager.get_share_id(context) + current_user_id = await get_current_user_id(context) + + digest = ShareStorage.read_knowledge_digest(share_id) + is_new = False + + if not digest: + digest = KnowledgeDigest( + created_by=current_user_id, + updated_by=current_user_id, + conversation_id=str(context.id), + content="", + ) + is_new = True + + digest.content = content + digest.is_auto_generated = is_auto_generated + digest.updated_at = datetime.now(UTC) + digest.updated_by = current_user_id + digest.version += 1 + ShareStorage.write_knowledge_digest(share_id, digest) + + # Log the update + event_type = LogEntryType.KNOWLEDGE_DIGEST_UPDATE + update_type = "auto-generated" if is_auto_generated else "manual" + message = f"{'Created' if is_new else 'Updated'} knowledge digest ({update_type})" + + await ShareManager.log_share_event( + context=context, + entry_type=event_type.value, + message=message, + ) + + await Notifications.notify_all_state_update( + context, + [InspectorTab.BRIEF], + ) + + return diff --git a/assistants/project-assistant/assistant/domain/learning_objectives_manager.py b/assistants/project-assistant/assistant/domain/learning_objectives_manager.py new file mode 100644 index 000000000..140f73036 --- /dev/null +++ b/assistants/project-assistant/assistant/domain/learning_objectives_manager.py @@ -0,0 +1,406 @@ +from semantic_workbench_assistant.assistant_app import ConversationContext + +from assistant.data import ( + InspectorTab, + LearningObjective, + LearningOutcome, + LearningOutcomeAchievement, + LogEntryType, + Share, +) +from assistant.notifications import Notifications + +from .share_manager import ShareManager + + +class LearningObjectivesManager: + """Manages learning objectives and outcomes operations.""" + + @staticmethod + async def add_learning_objective( + context: ConversationContext, + objective_name: str, + description: str, + outcomes: list[str] | None = None, + priority: int = 1, + ) -> LearningObjective | None: + criterion_objects = [] + if outcomes: + for criterion in outcomes: + criterion_objects.append(LearningOutcome(description=criterion)) + + new_learning_objective = LearningObjective( + name=objective_name, + description=description, + priority=priority, + learning_outcomes=criterion_objects, + ) + + share = await ShareManager.get_share(context) + share.learning_objectives.append(new_learning_objective) + await ShareManager.set_share(context, share) + + await ShareManager.log_share_event( + context=context, + entry_type=LogEntryType.LEARNING_OBJECTIVE_ADDED.value, + message=f"Added learning objective: {objective_name}", + ) + + await Notifications.notify_all(context, share.share_id, f"Learning objective '{objective_name}' was added") + await Notifications.notify_all_state_update(context, [InspectorTab.LEARNING, InspectorTab.BRIEF]) + + return new_learning_objective + + @staticmethod + async def update_learning_objective( + context: ConversationContext, + objective_id: str, + objective_name: str | None = None, + description: str | None = None, + ) -> str: + """ + Update an existing learning objective's name or description. + + Returns: + Success message + """ + share = await ShareManager.get_share(context) + if not share.learning_objectives: + raise ValueError("No learning objectives found") + + # Find objective by ID + objective = None + for obj in share.learning_objectives: + if obj.id == objective_id: + objective = obj + break + if not objective: + raise ValueError("Learning objective not found") + + original_name = objective.name + changes_made = [] + + # Update fields if provided + if objective_name and objective_name.strip(): + objective.name = objective_name.strip() + changes_made.append(f"name: '{original_name}' → '{objective_name.strip()}'") + + if description and description.strip(): + objective.description = description.strip() + changes_made.append("description updated") + + if not changes_made: + return "No changes specified" + + await ShareManager.set_share(context, share) + + changes_text = ", ".join(changes_made) + await ShareManager.log_share_event( + context=context, + entry_type=LogEntryType.LEARNING_OBJECTIVE_UPDATED.value, + message=f"Updated learning objective '{objective.name}': {changes_text}", + metadata={ + "objective_id": objective_id, + "objective_name": objective.name, + "changes": changes_text, + }, + ) + + await Notifications.notify_all( + context, share.share_id, f"Learning objective '{objective.name}' has been updated" + ) + await Notifications.notify_all_state_update(context, [InspectorTab.LEARNING, InspectorTab.BRIEF]) + + return f"Learning objective '{objective.name}' has been successfully updated: {changes_text}." + + @staticmethod + async def delete_learning_objective( + context: ConversationContext, + objective_id: str, + ) -> str: + """ + Delete a learning objective by ID. + + Returns: + Success message + """ + share = await ShareManager.get_share(context) + if not share.learning_objectives: + raise ValueError("No learning objectives found") + + # Find objective by ID + objective = None + objective_index = -1 + for idx, obj in enumerate(share.learning_objectives): + if obj.id == objective_id: + objective = obj + objective_index = idx + break + + if not objective: + raise ValueError("Learning objective not found.") + + objective_name = objective.name + + # Clean up any achievement records for all outcomes in this objective across all team conversations + for outcome in objective.learning_outcomes: + for team_info in share.team_conversations.values(): + team_info.outcome_achievements = [ + achievement + for achievement in team_info.outcome_achievements + if achievement.outcome_id != outcome.id + ] + + # Remove the objective from the share + share.learning_objectives.pop(objective_index) + + await ShareManager.set_share(context, share) + + await ShareManager.log_share_event( + context=context, + entry_type=LogEntryType.LEARNING_OBJECTIVE_UPDATED.value, + message=f"Deleted learning objective '{objective_name}' and all its outcomes", + metadata={ + "objective_id": objective_id, + "objective_name": objective_name, + "outcomes_count": len(objective.learning_outcomes), + }, + ) + + await Notifications.notify_all( + context, share.share_id, f"Learning objective '{objective_name}' has been deleted" + ) + await Notifications.notify_all_state_update(context, [InspectorTab.LEARNING, InspectorTab.BRIEF]) + + return f"Learning objective '{objective_name}' has been successfully deleted from the knowledge share." + + @staticmethod + async def get_learning_outcomes( + context: ConversationContext, + ) -> list[LearningOutcome]: + share = await ShareManager.get_share(context) + + objectives = share.learning_objectives + outcomes = [] + for objective in objectives: + outcomes.extend(objective.learning_outcomes) + + return outcomes + + @staticmethod + async def add_learning_outcome( + context: ConversationContext, + objective_id: str, + outcome_description: str, + ) -> str: + """ + Add a new learning outcome to an existing learning objective. + + Returns: + Success message + """ + share = await ShareManager.get_share(context) + if not share.learning_objectives: + raise ValueError("No learning objectives found. Please add objectives before adding outcomes.") + + objective = None + for obj in share.learning_objectives: + if obj.id == objective_id: + objective = obj + break + + if objective is None: + raise ValueError("Learning objective not found") + + new_outcome = LearningOutcome(description=outcome_description.strip()) + objective.learning_outcomes.append(new_outcome) + await ShareManager.set_share(context, share) + + await ShareManager.log_share_event( + context=context, + entry_type=LogEntryType.LEARNING_OBJECTIVE_UPDATED.value, + message=f"Added learning outcome to objective '{objective.name}': {outcome_description}", + metadata={ + "objective_id": objective_id, + "objective_name": objective.name, + "outcome_added": outcome_description, + "outcome_id": new_outcome.id, + }, + ) + + await Notifications.notify_all( + context, + share.share_id, + f"Learning outcome '{outcome_description}' has been added", + ) + await Notifications.notify_all_state_update(context, [InspectorTab.LEARNING, InspectorTab.BRIEF]) + + return f"Learning outcome added successfully to objective '{objective.name}': {outcome_description}" + + @staticmethod + async def update_learning_outcome( + context: ConversationContext, + outcome_id: str, + new_description: str, + ) -> str: + """ + Update the description of an existing learning outcome. + + Returns: + Success message + """ + share = await ShareManager.get_share(context) + if not share.learning_objectives: + raise ValueError("No learning objectives found. Please add objectives before updating outcomes.") + + # Find the outcome by ID across all objectives + objective = None + outcome = None + for obj in share.learning_objectives: + for out in obj.learning_outcomes: + if out.id == outcome_id: + objective = obj + outcome = out + break + if outcome: + break + + if outcome is None or objective is None: + # Collect available outcome IDs for error message + available_outcome_ids = [] + for obj in share.learning_objectives: + for out in obj.learning_outcomes: + available_outcome_ids.append(out.id) + raise ValueError("Learning outcome not found.") + + old_description = outcome.description + + outcome.description = new_description.strip() + await ShareManager.set_share(context, share) + + await ShareManager.log_share_event( + context=context, + entry_type=LogEntryType.LEARNING_OBJECTIVE_UPDATED.value, + message="Updated learning outcome.", + metadata={ + "objective_id": objective.id, + "objective_name": objective.name, + "outcome_id": outcome_id, + "old_description": old_description, + "new_description": new_description, + }, + ) + + await Notifications.notify_all( + context, share.share_id, f"Learning outcome '{new_description}' has been updated" + ) + await Notifications.notify_all_state_update(context, [InspectorTab.LEARNING, InspectorTab.BRIEF]) + + return f"Learning outcome updated successfully in objective '{objective.name}': {new_description}" + + @staticmethod + async def delete_learning_outcome( + context: ConversationContext, + outcome_id: str, + ) -> str: + """ + Delete a learning outcome from a learning objective. + + Returns: + Success message + """ + share = await ShareManager.get_share(context) + if not share.learning_objectives: + raise ValueError("No learning objectives found. Please add objectives before deleting outcomes.") + + # Find the outcome by ID across all objectives + objective = None + outcome_to_delete = None + outcome_index = -1 + for obj in share.learning_objectives: + for idx, out in enumerate(obj.learning_outcomes): + if out.id == outcome_id: + objective = obj + outcome_to_delete = out + outcome_index = idx + break + if outcome_to_delete: + break + + if outcome_to_delete is None or objective is None: + # Collect available outcome IDs for error message + available_outcome_ids = [] + for obj in share.learning_objectives: + for out in obj.learning_outcomes: + available_outcome_ids.append(out.id) + raise ValueError("Learning outcome not found.") + + deleted_description = outcome_to_delete.description + + # Remove the outcome from the objective + objective.learning_outcomes.pop(outcome_index) + + # Clean up any achievement records for this outcome across all team conversations + for team_info in share.team_conversations.values(): + team_info.outcome_achievements = [ + achievement for achievement in team_info.outcome_achievements if achievement.outcome_id != outcome_id + ] + + await ShareManager.set_share(context, share) + + await ShareManager.log_share_event( + context=context, + entry_type=LogEntryType.LEARNING_OBJECTIVE_UPDATED.value, + message=f"Deleted learning outcome from objective '{objective.name}': {deleted_description}", + metadata={ + "objective_id": objective.id, + "objective_name": objective.name, + "outcome_index": outcome_index, + "outcome_id": outcome_id, + "deleted_description": deleted_description, + }, + ) + + await Notifications.notify_all( + context, + share.share_id, + f"Learning outcome '{deleted_description}' has been removed", + ) + await Notifications.notify_all_state_update(context, [InspectorTab.LEARNING, InspectorTab.BRIEF]) + + return f"Learning outcome deleted successfully from objective '{objective.name}': {deleted_description}" + + @staticmethod + def get_achievements_for_conversation(share: Share, conversation_id: str) -> list[LearningOutcomeAchievement]: + team_conv = share.team_conversations.get(conversation_id) + return team_conv.outcome_achievements if team_conv else [] + + @staticmethod + def get_completion_for_conversation(share: Share, conversation_id: str) -> tuple[int, int]: + achievements = LearningObjectivesManager.get_achievements_for_conversation(share, conversation_id) + achieved_outcome_ids = {a.outcome_id for a in achievements if a.achieved} + + total_outcomes = sum(len(obj.learning_outcomes) for obj in share.learning_objectives) + achieved_outcomes = len(achieved_outcome_ids) + + return achieved_outcomes, total_outcomes + + @staticmethod + def is_outcome_achieved_by_conversation(share: Share, outcome_id: str, conversation_id: str) -> bool: + achievements = LearningObjectivesManager.get_achievements_for_conversation(share, conversation_id) + return any(a.outcome_id == outcome_id and a.achieved for a in achievements) + + @staticmethod + def get_overall_completion(share: Share) -> tuple[int, int]: + """ + Get overall completion across all team conversations. + Returns: + Tuple of (unique_achieved_outcomes, total_outcomes) across all team conversations + """ + all_achieved_outcomes = set() + for team_conv in share.team_conversations.values(): + achieved_ids = {a.outcome_id for a in team_conv.outcome_achievements if a.achieved} + all_achieved_outcomes.update(achieved_ids) + + total_outcomes = sum(len(obj.learning_outcomes) for obj in share.learning_objectives) + return len(all_achieved_outcomes), total_outcomes diff --git a/assistants/project-assistant/assistant/domain/share_manager.py b/assistants/project-assistant/assistant/domain/share_manager.py new file mode 100644 index 000000000..b2355f847 --- /dev/null +++ b/assistants/project-assistant/assistant/domain/share_manager.py @@ -0,0 +1,395 @@ +import uuid +from csv import Error +from datetime import UTC, datetime + +from semantic_workbench_api_model.workbench_model import ( + ConversationPermission, + NewConversation, + NewConversationShare, +) +from semantic_workbench_assistant.assistant_app import ConversationContext +from semantic_workbench_assistant.storage import read_model, write_model + +from assistant.data import ( + ConversationRole, + ConversationShareInfo, + CoordinatorConversationMessages, + Share, + ShareLog, + TeamConversationInfo, +) +from assistant.errors import NoShareException, NoShareFoundError +from assistant.logging import logger +from assistant.storage import ShareStorage, ShareStorageManager +from assistant.utils import get_current_user + + +class ShareManager: + """Manages knowledge share creation, joining, and basic operations.""" + + @staticmethod + async def create_share(context: ConversationContext) -> str: + """ + Creates a new knowledge share and associates the current conversation with it. + + This is the initial step in knowledge transfer creation. It: + 1. Generates a unique knowledge share ID + 2. Associates the current conversation with that share + 3. Sets the current conversation as Coordinator for the share + 4. Creates empty share data structures (brief, knowledge digest, etc.) + 5. Logs the creation event + + After creating a share, the Coordinator should proceed to create a knowledge brief + with specific learning objectives and success criteria. + + Args: + context: Current conversation context containing user/assistant information + + Returns: + Tuple of (success, share_id) where: + - success: Boolean indicating if the creation was successful + - share_id: If successful, the UUID of the newly created share + """ + + share_id = str(uuid.uuid4()) + + share_dir = ShareStorageManager.get_share_dir(share_id) + logger.debug(f"Created share directory: {share_dir}") + + share = Share( + share_id=share_id, + coordinator_conversation_id=str(context.id), + brief=None, + digest=None, + ) + + ShareStorage.write_share(share_id, share) + logger.debug(f"Created and saved knowledge share: {share}") + + # Associate the conversation with the share + logger.debug(f"Associating conversation {context.id} with share {share_id}") + await ShareManager.set_conversation_role(context, share_id, ConversationRole.COORDINATOR) + + # No need to set conversation role in share storage, as we use metadata + logger.debug(f"Conversation {context.id} is Coordinator for share {share_id}") + + return share_id + + @staticmethod + async def set_conversation_role(context: ConversationContext, share_id: str, role: ConversationRole) -> None: + """ + Sets the role of a conversation in a knowledge transfer share. + """ + role_data = ConversationShareInfo(share_id=share_id, role=role, conversation_id=str(context.id)) + role_path = ShareStorageManager.get_conversation_role_file_path(context) + write_model(role_path, role_data) + + @staticmethod + async def get_conversation_role( + context: ConversationContext, + ) -> ConversationRole: + """ + Gets the role of a conversation in a knowledge transfer. + """ + role_path = ShareStorageManager.get_conversation_role_file_path(context) + role_data = read_model(role_path, ConversationShareInfo) + if role_data: + return role_data.role + return ConversationRole.COORDINATOR + + @staticmethod + async def create_shareable_team_conversation(context: ConversationContext, share_id: str) -> str: + """ + Creates a new shareable team conversation template. + + This creates a new conversation owned by the same user as the current conversation, + intended to be used as a shareable team conversation template. This is NOT a + conversation that anyone will directly use. Instead, it's a template that gets + copied when team members redeem the share URL, creating their own individual + team conversations. + + The conversation is tagged with metadata indicating its purpose and gets a + share URL that can be used by team members to join the knowledge transfer. + + Args: + context: Current conversation context + share_id: ID of the share + + Returns: + share_url: URL for joining a team conversation + """ + + new_conversation = NewConversation( + metadata={ + "share_id": share_id, + }, + ) + owner_id, _ = await get_current_user(context) + logger.debug(f"Creating shareable conversation with owner {owner_id}") + conversation = await context._conversations_client.create_conversation_with_owner( + new_conversation=new_conversation, owner_id=owner_id + ) + if not conversation or not conversation.id: + raise ValueError("Failed to create shareable conversation") + logger.debug(f"Created shareable team conversation: {conversation.id}") + + # Create a share link for the new conversation + new_share_link = NewConversationShare( + conversation_id=conversation.id, + label="Join Team Conversation", + conversation_permission=ConversationPermission.read, + metadata={ + "share_id": share_id, + "is_team_conversation": True, + "showDuplicateAction": True, + "show_duplicate_action": True, + }, + ) + share_link = await context._conversations_client.create_conversation_share_with_owner( + new_conversation_share=new_share_link, owner_id=owner_id + ) + + share_url = f"/conversation-share/{share_link.id}/redeem" + + # Capture the new conversation and conversation share link ids in the share. + share = await ShareManager.get_share(context) + share.shared_conversation_id = str(conversation.id) + share.share_url = share_url + share.updated_at = datetime.now(UTC) + ShareStorage.write_share(share_id, share) + return share_url + + @staticmethod + async def get_shared_conversation_id(context: ConversationContext) -> str | None: + """ + Retrieves the share ID and finds the associated shareable template conversation ID. + """ + try: + share = await ShareManager.get_share(context) + if not share.shared_conversation_id: + return None + return share.shared_conversation_id + except Exception as e: + logger.error(f"Error getting shared conversation ID: {e}") + return None + + @staticmethod + async def join_share( + context: ConversationContext, + share_id: str, + role: ConversationRole = ConversationRole.TEAM, + ) -> None: + """ + Joins an existing share. + + Args: + context: Current conversation context + share_id: ID of the share to join + role: Role for this conversation (COORDINATOR or TEAM) + """ + if not ShareStorageManager.share_exists(share_id): + raise ValueError(f"Cannot join share: share {share_id} does not exist") + + # Associate the conversation with the share + await ShareManager.set_conversation_role(context, share_id, role) + + # Role is set in metadata, not in storage + + logger.info(f"Joined share {share_id} as {role.value}") + + @staticmethod + async def get_share_id(context: ConversationContext) -> str: + try: + share_path = ShareStorageManager.get_conversation_role_file_path(context) + share_data = read_model(share_path, ConversationShareInfo) + if share_data: + return share_data.share_id + else: + raise NoShareException + except Error as e: + raise NoShareFoundError(str(e)) from e + + @staticmethod + async def get_share(context: ConversationContext) -> Share: + """Gets the share information for the current conversation's share.""" + try: + share_id = await ShareManager.get_share_id(context) + share = ShareStorage.read_share(share_id) + if share: + # Load the separate log file if not already loaded + if not share.log: + share.log = ShareStorage.read_share_log(share_id) + return share + else: + raise NoShareException + except Error as e: + raise NoShareFoundError(str(e)) from e + + @staticmethod + async def set_share(context: ConversationContext, share: Share) -> None: + """ + Sets the share information for the current conversation's share. + + This updates the share data in storage, including the log if it exists. + """ + share_id = await ShareManager.get_share_id(context) + ShareStorage.write_share(share_id, share) + + # If the log exists, write it as well + if share.log: + ShareStorage.write_share_log(share_id, share.log) + + @staticmethod + async def get_linked_conversations(context: ConversationContext) -> list[str]: + """ + Gets all conversations linked to this one through the same knowledge transfer share. + """ + try: + share = await ShareManager.get_share(context) + conversations = [] + + # Add coordinator conversation + if share.coordinator_conversation_id: + conversations.append(share.coordinator_conversation_id) + + # Add shared template conversation (though usually excluded from notifications) + if share.shared_conversation_id and share.shared_conversation_id: + conversations.append(share.shared_conversation_id) + + # Add all team conversations + for conversation_id in share.team_conversations: + conversations.append(conversation_id) + + return [] + + except Exception as e: + logger.error(f"Error getting linked conversations: {e}") + return [] + + @staticmethod + async def _capture_redeemer_info(context: ConversationContext, share_id: str) -> None: + """ + Captures the redeemer (first non-assistant participant) information and stores it in the knowledge share. + Only captures info for actual team member conversations, not coordinator or shared conversations. + """ + try: + share = ShareStorage.read_share(share_id) + if not share: + logger.warning(f"Could not load knowledge share {share_id} to capture redeemer info") + return + + conversation_id = str(context.id) + + # Skip if this is the coordinator conversation + if conversation_id == share.coordinator_conversation_id: + logger.debug(f"Skipping redeemer capture for coordinator conversation {conversation_id}") + return + + # Skip if this is the shared conversation template + if conversation_id == share.shared_conversation_id: + logger.debug(f"Skipping redeemer capture for shared conversation template {conversation_id}") + return + + # If we get here, it's a team member conversation - capture redeemer info + # Get current user information (the redeemer) + user_id, user_name = await get_current_user(context) + + if not user_id or not user_name: + logger.warning(f"Could not identify redeemer for conversation {conversation_id}") + return + + team_conversation_info = TeamConversationInfo( + conversation_id=conversation_id, + redeemer_user_id=user_id, + redeemer_name=user_name, + ) + share.team_conversations[conversation_id] = team_conversation_info + ShareStorage.write_share(share_id, share) + logger.debug(f"Captured redeemer info for team conversation {conversation_id}: {user_name} ({user_id})") + + except Exception as e: + logger.error(f"Error capturing redeemer info: {e}") + # Don't re-raise - this is not critical for the association process + + @staticmethod + async def get_share_log( + context: ConversationContext, + ) -> ShareLog | None: + """Gets the knowledge transfer log for the current conversation's share.""" + try: + share_id = await ShareManager.get_share_id(context) + return ShareStorage.read_share_log(share_id) + except Error as e: + logger.error(f"Error reading share log: {e}") + return None + + @staticmethod + async def append_coordinator_message( + context: ConversationContext, + message_id: str, + content: str, + sender_name: str, + is_assistant: bool = False, + timestamp: datetime | None = None, + ) -> None: + """ + Appends a message to the coordinator conversation log. + + This is used to log messages sent by the coordinator in the knowledge transfer process. + """ + try: + share_id = await ShareManager.get_share_id(context) + ShareStorage.append_coordinator_message( + share_id=share_id, + message_id=message_id, + content=content, + sender_name=sender_name, + is_assistant=is_assistant, + timestamp=timestamp or datetime.now(UTC), + ) + except Exception as e: + logger.error(f"Error appending coordinator message: {e}") + + @staticmethod + async def get_coordinator_conversation( + context: ConversationContext, + ) -> CoordinatorConversationMessages | None: + """ + Gets the coordinator conversation. + """ + share_id = await ShareManager.get_share_id(context) + if share_id: + return ShareStorage.read_coordinator_conversation(share_id) + return None + + @staticmethod + async def log_share_event( + context: ConversationContext, + entry_type: str, + message: str, + related_entity_id: str | None = None, + metadata: dict | None = None, + ) -> None: + """ + Logs an event to the knowledge transfer log. + + Args: + context: Current conversation context + entry_type: Type of log entry + message: Log message + related_entity_id: Optional ID of a related entity + metadata: Optional additional metadata + """ + try: + share_id = await ShareManager.get_share_id(context) + + await ShareStorage.log_share_event( + context=context, + share_id=share_id, + entry_type=entry_type, + message=message, + related_entity_id=related_entity_id, + metadata=metadata, + ) + except Error as e: + logger.error(f"Error logging share event: {e}") diff --git a/assistants/project-assistant/assistant/domain/tasks_manager.py b/assistants/project-assistant/assistant/domain/tasks_manager.py new file mode 100644 index 000000000..86d717eb9 --- /dev/null +++ b/assistants/project-assistant/assistant/domain/tasks_manager.py @@ -0,0 +1,56 @@ +from semantic_workbench_assistant.assistant_app import ConversationContext + +from assistant.data import NewTaskInfo, TaskInfo +from assistant.domain.share_manager import ShareManager +from assistant.storage import ShareStorage + + +class TasksManager: + @staticmethod + async def get_tasks( + context: ConversationContext, + ) -> list[TaskInfo]: + share_id = await ShareManager.get_share_id(context) + if not share_id: + return [] + return ShareStorage.read_tasks(share_id) + + @staticmethod + async def add_tasks( + context: ConversationContext, + tasks: list[NewTaskInfo], + ) -> None: + share_id = await ShareManager.get_share_id(context) + if not share_id: + return + ShareStorage.add_tasks(share_id, tasks) + + @staticmethod + async def update_task( + context: ConversationContext, + task: TaskInfo, + ) -> None: + share_id = await ShareManager.get_share_id(context) + if not share_id: + return + ShareStorage.update_task(share_id, task) + + @staticmethod + async def remove_task( + context: ConversationContext, + task_id: str, + ) -> None: + share_id = await ShareManager.get_share_id(context) + if not share_id: + return + ShareStorage.remove_task(share_id, task_id) + + @staticmethod + async def set_task_list( + context: ConversationContext, + tasks: list[TaskInfo], + ) -> None: + share_id = await ShareManager.get_share_id(context) + if not share_id: + return + ShareStorage.set_all_tasks(share_id, tasks) diff --git a/assistants/project-assistant/assistant/domain/transfer_manager.py b/assistants/project-assistant/assistant/domain/transfer_manager.py new file mode 100644 index 000000000..60adc7c0f --- /dev/null +++ b/assistants/project-assistant/assistant/domain/transfer_manager.py @@ -0,0 +1,34 @@ +from assistant.data import Share + +from .learning_objectives_manager import LearningObjectivesManager + + +class TransferManager: + @staticmethod + def is_ready_for_transfer(share: Share) -> bool: + # has_basic_requirements = share.knowledge_organized and share.brief is not None and share.audience is not None + has_basic_requirements = share.audience and share.audience_takeaways and share.brief + if not has_basic_requirements: + return False + + if not share.is_intended_to_accomplish_outcomes: + return True + + return bool(share.learning_objectives) and any(bool(obj.learning_outcomes) for obj in share.learning_objectives) + + @staticmethod + def is_actively_sharing(share: Share) -> bool: + return TransferManager.is_ready_for_transfer(share) and len(share.team_conversations) > 0 + + @staticmethod + def _is_transfer_complete(share: Share) -> bool: + """ + Check if knowledge transfer is complete (all outcomes achieved by at least one team member). + Returns: + True if all learning outcomes have been achieved by at least one team conversation + """ + if not share.is_intended_to_accomplish_outcomes: + return False + + achieved_outcomes, total_outcomes = LearningObjectivesManager.get_overall_completion(share) + return total_outcomes > 0 and achieved_outcomes == total_outcomes diff --git a/assistants/project-assistant/assistant/errors.py b/assistants/project-assistant/assistant/errors.py new file mode 100644 index 000000000..711a98950 --- /dev/null +++ b/assistants/project-assistant/assistant/errors.py @@ -0,0 +1,21 @@ +# Define an Exception class for no share found +class NoShareFoundError(Exception): + """Exception raised when no active knowledge share is found.""" + + def __init__(self, message: str): + super().__init__(message) + self.message = message + + +NoShareException = NoShareFoundError("No active share found.") + + +class NoUserFoundError(Exception): + """Exception raised when no user is found in the context.""" + + def __init__(self, message: str): + super().__init__(message) + self.message = message + + +NoUserException = NoUserFoundError("No user found in the context.") diff --git a/assistants/project-assistant/assistant/project_files.py b/assistants/project-assistant/assistant/files.py similarity index 70% rename from assistants/project-assistant/assistant/project_files.py rename to assistants/project-assistant/assistant/files.py index 421867011..a8ff1792d 100644 --- a/assistants/project-assistant/assistant/project_files.py +++ b/assistants/project-assistant/assistant/files.py @@ -9,20 +9,20 @@ import io import pathlib from datetime import datetime -from typing import Dict, List, Optional from pydantic import BaseModel, Field from semantic_workbench_api_model import workbench_model -from semantic_workbench_api_model.workbench_model import MessageType, NewConversationMessage +from semantic_workbench_api_model.workbench_model import ( + MessageType, + NewConversationMessage, +) from semantic_workbench_assistant.assistant_app import ConversationContext from .conversation_clients import ConversationClientManager -from .conversation_project_link import ConversationProjectManager +from .data import ConversationRole, LogEntryType +from .domain.share_manager import ShareManager from .logging import logger -from .project_common import detect_assistant_role -from .project_data import LogEntryType -from .project_storage import ProjectStorage, ProjectStorageManager, read_model, write_model -from .project_storage_models import ConversationRole +from .storage import ShareStorageManager, read_model, write_model # Define helper function for safe logging without 'filename' conflict @@ -42,8 +42,8 @@ def safe_extra(log_data): return safe_data -class ProjectFile(BaseModel): - """Metadata for a file shared within a project.""" +class ShareFile(BaseModel): + """Metadata for a file shared within a share.""" file_id: str filename: str @@ -56,79 +56,79 @@ class ProjectFile(BaseModel): is_coordinator_file: bool = True # Whether this file was created by Coordinator -class ProjectFileCollection(BaseModel): - """Collection of file metadata for a project.""" +class ShareCollection(BaseModel): + """Collection of file metadata for a share.""" - files: List[ProjectFile] = Field(default_factory=list) + files: list[ShareFile] = Field(default_factory=list) -class ProjectFileManager: +class ShareFilesManager: """ - Manages shared project files. + Manages shared knowledge transfer files. Provides functionality for copying files between conversations and maintaining - a synchronized file repository for each project. + a synchronized file repository for each knowledge share. """ @staticmethod - def get_project_files_dir(project_id: str) -> pathlib.Path: + def get_share_files_dir(share_id: str) -> pathlib.Path: """ - Gets the directory for project files. + Gets the directory for share files. """ - project_dir = ProjectStorageManager.get_project_dir(project_id) - files_dir = project_dir / "files" + share_dir = ShareStorageManager.get_share_dir(share_id) + files_dir = share_dir / "files" files_dir.mkdir(parents=True, exist_ok=True) return files_dir @staticmethod - def get_file_metadata_path(project_id: str) -> pathlib.Path: + def get_file_metadata_path(share_id: str) -> pathlib.Path: """ Gets the path to the file metadata JSON. """ - files_dir = ProjectFileManager.get_project_files_dir(project_id) + files_dir = ShareFilesManager.get_share_files_dir(share_id) return files_dir / "file_metadata.json" @staticmethod - def get_file_path(project_id: str, filename: str) -> pathlib.Path: + def get_file_path(share_id: str, filename: str) -> pathlib.Path: """ - Gets the path to a specific file in the project. + Gets the path to a specific file in the share. """ - files_dir = ProjectFileManager.get_project_files_dir(project_id) + files_dir = ShareFilesManager.get_share_files_dir(share_id) return files_dir / filename @staticmethod - def read_file_metadata(project_id: str) -> ProjectFileCollection: + def read_file_metadata(share_id: str) -> ShareCollection: """ - Reads file metadata for a project. + Reads file metadata for a share. """ - path = ProjectFileManager.get_file_metadata_path(project_id) - return read_model(path, ProjectFileCollection) or ProjectFileCollection( + path = ShareFilesManager.get_file_metadata_path(share_id) + return read_model(path, ShareCollection) or ShareCollection( files=[], ) @staticmethod - def write_file_metadata(project_id: str, metadata: ProjectFileCollection) -> pathlib.Path: + def write_file_metadata(share_id: str, metadata: ShareCollection) -> pathlib.Path: """ - Writes file metadata for a project. + Writes file metadata for a share. """ - path = ProjectFileManager.get_file_metadata_path(project_id) + path = ShareFilesManager.get_file_metadata_path(share_id) write_model(path, metadata) return path @staticmethod - async def copy_file_to_project_storage( + async def copy_file_to_share_storage( context: ConversationContext, - project_id: str, + share_id: str, file: workbench_model.File, is_coordinator_file: bool = True, ) -> bool: """ - Copies a file from a conversation to project storage. + Copies a file from a conversation to share storage. """ # Create safe log data for debugging log_extra = { "file_name": file.filename, - "project_id": project_id, + "share_id": share_id, "conversation_id": str(context.id), "file_size": getattr(file, "file_size", 0), "is_coordinator_file": is_coordinator_file, @@ -140,10 +140,13 @@ async def copy_file_to_project_storage( logger.error("Missing filename in file metadata", extra=safe_extra(log_extra)) return False - # Check if project storage directory exists - files_dir = ProjectFileManager.get_project_files_dir(project_id) + # Check if share storage directory exists + files_dir = ShareFilesManager.get_share_files_dir(share_id) if not files_dir.exists(): - logger.debug(f"Creating project files directory: {files_dir}", extra=safe_extra(log_extra)) + logger.debug( + f"Creating knowledge transfer files directory: {files_dir}", + extra=safe_extra(log_extra), + ) files_dir.mkdir(parents=True, exist_ok=True) # Read the file from the conversation with error handling @@ -157,36 +160,43 @@ async def copy_file_to_project_storage( buffer_size = buffer.tell() if buffer_size == 0: logger.error( - "Failed to read file content from conversation - buffer is empty", extra=safe_extra(log_extra) + "Failed to read file content from conversation - buffer is empty", + extra=safe_extra(log_extra), ) return False except Exception as read_error: - logger.error(f"Error reading file from conversation: {read_error}", extra=safe_extra(log_extra)) + logger.error( + f"Error reading file from conversation: {read_error}", + extra=safe_extra(log_extra), + ) return False buffer.seek(0) - # Write the file to project storage - file_path = ProjectFileManager.get_file_path(project_id, file.filename) + # Write the file to share storage + file_path = ShareFilesManager.get_file_path(share_id, file.filename) try: - with open(file_path, "wb") as f: + with file_path.open("wb") as f: f.write(buffer.getvalue()) # Verify file was written if not file_path.exists() or file_path.stat().st_size == 0: logger.error( - "Failed to write file to project storage - file is missing or empty", + "Failed to write file to share storage - file is missing or empty", extra=safe_extra(log_extra), ) return False except Exception as write_error: - logger.error(f"Error writing file to project storage: {write_error}", extra=safe_extra(log_extra)) + logger.error( + f"Error writing file to share storage: {write_error}", + extra=safe_extra(log_extra), + ) return False # Store file metadata - file_metadata = ProjectFile( + file_metadata = ShareFile( file_id=str(getattr(file, "id", "")), filename=file.filename, content_type=file.content_type, @@ -200,62 +210,80 @@ async def copy_file_to_project_storage( # Add to metadata collection with error handling try: - metadata_path = ProjectFileManager.get_file_metadata_path(project_id) - logger.debug(f"Reading metadata from {metadata_path}", extra=safe_extra(log_extra)) + metadata_path = ShareFilesManager.get_file_metadata_path(share_id) + logger.debug( + f"Reading metadata from {metadata_path}", + extra=safe_extra(log_extra), + ) - metadata = read_model(metadata_path, ProjectFileCollection) + metadata = read_model(metadata_path, ShareCollection) if not metadata: # Create new collection - metadata = ProjectFileCollection( + metadata = ShareCollection( files=[], ) # Check if file already exists in collection - existing_idx = next((i for i, f in enumerate(metadata.files) if f.filename == file.filename), None) + existing_idx = next( + (i for i, f in enumerate(metadata.files) if f.filename == file.filename), + None, + ) if existing_idx is not None: metadata.files[existing_idx] = file_metadata else: metadata.files.append(file_metadata) # Save metadata - ProjectFileManager.write_file_metadata(project_id, metadata) + ShareFilesManager.write_file_metadata(share_id, metadata) # Verify metadata was written if not metadata_path.exists(): - logger.error(f"Failed to write metadata file {metadata_path}", extra=safe_extra(log_extra)) + logger.error( + f"Failed to write metadata file {metadata_path}", + extra=safe_extra(log_extra), + ) return False # Final check - verify file appears in metadata - verification_metadata = read_model(metadata_path, ProjectFileCollection) + verification_metadata = read_model(metadata_path, ShareCollection) if not verification_metadata: - logger.error("Metadata file exists but can't be read", extra=safe_extra(log_extra)) + logger.error( + "Metadata file exists but can't be read", + extra=safe_extra(log_extra), + ) return False file_exists_in_metadata = any(f.filename == file.filename for f in verification_metadata.files) if not file_exists_in_metadata: logger.error( - f"File metadata doesn't contain entry for {file.filename}", extra=safe_extra(log_extra) + f"File metadata doesn't contain entry for {file.filename}", + extra=safe_extra(log_extra), ) return False except Exception as metadata_error: - logger.error(f"Error updating metadata: {metadata_error}", extra=safe_extra(log_extra)) + logger.error( + f"Error updating metadata: {metadata_error}", + extra=safe_extra(log_extra), + ) return False return True except Exception as e: - logger.exception(f"Error copying file to project storage: {e}", extra=safe_extra(log_extra)) + logger.exception(f"Error copying file to share storage: {e}", extra=safe_extra(log_extra)) return False @staticmethod - async def delete_file_from_project_storage(context: ConversationContext, project_id: str, filename: str) -> bool: + async def delete_file_from_knowledge_share_storage( + context: ConversationContext, share_id: str, filename: str + ) -> bool: """ - Deletes a file from project storage. + Deletes a file from share storage. """ try: # Get the file path - file_path = ProjectFileManager.get_file_path(project_id, filename) + file_path = ShareFilesManager.get_file_path(share_id, filename) if not file_path.exists(): return True # File doesn't exist, nothing to delete @@ -263,8 +291,8 @@ async def delete_file_from_project_storage(context: ConversationContext, project file_path.unlink() # Update metadata - metadata_path = ProjectFileManager.get_file_metadata_path(project_id) - metadata = read_model(metadata_path, ProjectFileCollection) + metadata_path = ShareFilesManager.get_file_metadata_path(share_id) + metadata = read_model(metadata_path, ShareCollection) if not metadata: return True # No metadata to update @@ -272,29 +300,29 @@ async def delete_file_from_project_storage(context: ConversationContext, project metadata.files = [f for f in metadata.files if f.filename != filename] # Save metadata - ProjectFileManager.write_file_metadata(project_id, metadata) + ShareFilesManager.write_file_metadata(share_id, metadata) # Also notify Team conversations to delete their copies - await ProjectFileManager.notify_team_conversations_file_deleted( - context=context, project_id=project_id, filename=filename + await ShareFilesManager.notify_team_conversations_file_deleted( + context=context, share_id=share_id, filename=filename ) return True except Exception as e: - logger.exception(f"Error deleting file from project storage: {e}") + logger.exception(f"Error deleting file from share storage: {e}") return False @staticmethod async def notify_team_conversations_file_deleted( - context: ConversationContext, project_id: str, filename: str + context: ConversationContext, share_id: str, filename: str ) -> None: """ Notifies Team conversations to delete a file that was deleted by the Coordinator. """ try: # Get Team conversations - team_conversations = await ProjectFileManager.get_team_conversations(context, project_id) + team_conversations = await ShareFilesManager.get_team_conversations(context, share_id) if not team_conversations: return @@ -328,24 +356,24 @@ async def notify_team_conversations_file_deleted( @staticmethod async def copy_file_to_conversation( context: ConversationContext, - project_id: str, + share_id: str, filename: str, target_conversation_id: str, ) -> bool: """ - Copies a file from project storage to a target conversation. + Copies a file from share storage to a target conversation. """ try: - # Check if the file exists in project storage - file_path = ProjectFileManager.get_file_path(project_id, filename) + # Check if the file exists in share storage + file_path = ShareFilesManager.get_file_path(share_id, filename) if not file_path.exists(): - logger.warning(f"File {filename} not found in project storage") + logger.warning(f"File {filename} not found in share storage") return False # Get file metadata - metadata = ProjectFileManager.read_file_metadata(project_id) + metadata = ShareFilesManager.read_file_metadata(share_id) if not metadata: - logger.warning(f"No file metadata found for project {project_id}") + logger.warning(f"No file metadata found for share {share_id}") return False # Find the file metadata @@ -362,7 +390,7 @@ async def copy_file_to_conversation( # Read the file content try: - with open(file_path, "rb") as f: + with file_path.open("rb") as f: file_bytes = f.read() if not file_bytes: @@ -398,7 +426,11 @@ async def copy_file_to_conversation( # Upload the file try: file_content.seek(0) # Reset position to start of file - await target_client.write_file(filename=filename, file_content=file_content, content_type=content_type) + await target_client.write_file( + filename=filename, + file_content=file_content, + content_type=content_type, + ) logger.debug(f"Successfully uploaded file {filename}") return True except Exception as upload_error: @@ -410,21 +442,21 @@ async def copy_file_to_conversation( return False @staticmethod - async def get_team_conversations(context: ConversationContext, project_id: str) -> List[str]: + async def get_team_conversations(context: ConversationContext, share_id: str) -> list[str]: """ - Gets all Team conversation IDs for a project. + Gets all Team conversation IDs for a share. """ try: # Get linked conversations - linked_conversations = await ConversationProjectManager.get_linked_conversations(context) + linked_conversations = await ShareManager.get_linked_conversations(context) # Filter for team conversations team_conversations = [] for conv_id in linked_conversations: # Check if this is a team conversation - temp_context = await ProjectFileManager.create_temporary_context(context, conv_id) + temp_context = await ShareFilesManager.create_temporary_context(context, conv_id) if temp_context: - role = await ConversationProjectManager.get_conversation_role(temp_context) + role = await ShareManager.get_conversation_role(temp_context) if role == ConversationRole.TEAM: team_conversations.append(conv_id) @@ -437,7 +469,7 @@ async def get_team_conversations(context: ConversationContext, project_id: str) @staticmethod async def create_temporary_context( source_context: ConversationContext, target_conversation_id: str - ) -> Optional[ConversationContext]: + ) -> ConversationContext | None: """ Creates a temporary context for a target conversation. """ @@ -453,15 +485,15 @@ async def create_temporary_context( @staticmethod async def synchronize_files_to_team_conversation( context: ConversationContext, - project_id: str, + share_id: str, ) -> None: """ - Synchronize all project files to a Team conversation. + Synchronize all share files to a Team conversation. """ - logger.debug(f"Starting file synchronization for project {project_id}") + logger.debug(f"Starting file synchronization for share {share_id}") - # Get file metadata for the project - metadata = ProjectFileManager.read_file_metadata(project_id) + # Get file metadata for the share + metadata = ShareFilesManager.read_file_metadata(share_id) if not metadata or not metadata.files: # No metadata found @@ -493,9 +525,9 @@ async def synchronize_files_to_team_conversation( continue # Try to copy the file - success = await ProjectFileManager.copy_file_to_conversation( + success = await ShareFilesManager.copy_file_to_conversation( context=context, - project_id=project_id, + share_id=share_id, filename=file_meta.filename, target_conversation_id=str(context.id), ) @@ -535,9 +567,8 @@ async def synchronize_files_to_team_conversation( f"Synchronized files to Team conversation: {len(successful_files)} new, {len(skipped_files)} existing" ) - await ProjectStorage.log_project_event( + await ShareManager.log_share_event( context=context, - project_id=project_id, entry_type=LogEntryType.FILE_SHARED, message=sync_message, metadata={ @@ -548,13 +579,13 @@ async def synchronize_files_to_team_conversation( ) @staticmethod - async def get_shared_files(context: ConversationContext, project_id: str) -> Dict[str, ProjectFile]: + async def get_shared_files(context: ConversationContext, share_id: str) -> dict[str, ShareFile]: """ - Gets all shared files for a project with filename as key. + Gets all shared files for a share with filename as key. """ try: - # Get file metadata for the project - metadata = ProjectFileManager.read_file_metadata(project_id) + # Get file metadata for the share + metadata = ShareFilesManager.read_file_metadata(share_id) if not metadata or not metadata.files: return {} @@ -567,15 +598,15 @@ async def get_shared_files(context: ConversationContext, project_id: str) -> Dic return {} @staticmethod - async def process_file_update_notification( - context: ConversationContext, project_id: str, update_type: str, filename: str + async def process_share_update_notification( + context: ConversationContext, share_id: str, update_type: str, filename: str ) -> bool: """ Processes a file update notification in a Team conversation. """ try: # First verify that this is a Team conversation - role = await detect_assistant_role(context) + role = await ShareManager.get_conversation_role(context) if role != ConversationRole.TEAM: logger.warning("Only Team conversations should process file update notifications") @@ -583,9 +614,12 @@ async def process_file_update_notification( # Process based on update type if update_type == "file_created" or update_type == "file_updated": - # Synchronize the specific file from project storage - success = await ProjectFileManager.copy_file_to_conversation( - context=context, project_id=project_id, filename=filename, target_conversation_id=str(context.id) + # Synchronize the specific file from share storage + success = await ShareFilesManager.copy_file_to_conversation( + context=context, + share_id=share_id, + filename=filename, + target_conversation_id=str(context.id), ) action = "added" if update_type == "file_created" else "updated" diff --git a/assistants/project-assistant/assistant/logging.py b/assistants/project-assistant/assistant/logging.py index 75186da65..2e865454a 100644 --- a/assistants/project-assistant/assistant/logging.py +++ b/assistants/project-assistant/assistant/logging.py @@ -1,23 +1,22 @@ """ -Logging utilities for project assistant. +Logging utilities for knowledge transfer assistant. -This module provides enhanced logging capabilities for the project assistant, +This module provides enhanced logging capabilities for the knowledge transfer assistant, including JSON formatting and file logging. """ import json import logging -import os import platform from datetime import datetime from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any from uuid import UUID from pydantic import BaseModel # Configure the root logger -logger = logging.getLogger("project-assistant") +logger = logging.getLogger("knowledge-transfer-assistant") logger.setLevel(logging.DEBUG) # Ensure propagation is enabled to allow logs to reach the root handler @@ -75,7 +74,7 @@ def format(self, record) -> str: "timestamp": self.formatTime(record, self.datefmt), "level": record.levelname, "conversation_id": record_dict.get("conversation_id", None), - "project_id": record_dict.get("project_id", None), + "share_id": record_dict.get("share_id", None), "message": record.getMessage(), "data": record_dict.get("data", None), "module": record.module, @@ -100,7 +99,7 @@ def format(self, record) -> str: "message", "asctime", "conversation_id", - "project_id", + "share_id", "data", ] } @@ -121,24 +120,21 @@ def format(self, record) -> str: return json.dumps(simple_record) -def setup_file_logging(log_dir: Optional[str] = None) -> Path: +def setup_file_logging(log_dir: str | None = None) -> Path: """ Set up file logging with JSON formatting. Args: - log_dir: Directory for log files. If None, uses the project's .data/logs/ directory + log_dir: Directory for log files. If None, uses the knowledge transfer's .data/logs/ directory Returns: Path to the log file """ - # By default, store logs in the project's .data directory if log_dir is None: - # Get the directory where the current module is located current_file = Path(__file__) - project_dir = current_file.parent.parent # Go up to project-assistant directory - log_path = project_dir / ".data" / "logs" + share_dir = current_file.parent.parent + log_path = share_dir / ".data" / "logs" else: - # Use the provided directory log_path = Path(log_dir) # Ensure directory exists @@ -146,8 +142,8 @@ def setup_file_logging(log_dir: Optional[str] = None) -> Path: # Create log file path with timestamp to avoid conflicts timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - log_file = log_path / f"project_assistant_{timestamp}.json" - line_log_file = log_path / f"project_assistant_{timestamp}.log" # Add a regular log file too + log_file = log_path / f"log_{timestamp}.json" + line_log_file = log_path / f"log_{timestamp}.log" # Add a regular log file too try: # Remove any existing file handlers to avoid duplicates @@ -172,7 +168,7 @@ def setup_file_logging(log_dir: Optional[str] = None) -> Path: extra={ "system": platform.system(), "python_version": platform.python_version(), - "app": "project-assistant", + "app": "knowledge-transfer-assistant", "path": str(log_file.absolute()), }, ) @@ -186,8 +182,8 @@ def setup_file_logging(log_dir: Optional[str] = None) -> Path: try: import stat - os.chmod(log_file, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) - os.chmod(line_log_file, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) + Path(log_file).chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) + Path(line_log_file).chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) except Exception as e: logger.warning(f"Could not set log file permissions: {e}") print(f"Permission error: {e}") @@ -200,11 +196,11 @@ def setup_file_logging(log_dir: Optional[str] = None) -> Path: try: # Try a different subfolder in the .data directory current_file = Path(__file__) - project_dir = current_file.parent.parent # Go up to project-assistant directory - fallback_dir = project_dir / ".data" / "fallback_logs" - os.makedirs(fallback_dir, exist_ok=True) - log_file = Path(fallback_dir) / f"project_assistant_{timestamp}.json" - line_log_file = Path(fallback_dir) / f"project_assistant_{timestamp}.log" + share_dir = current_file.parent.parent + fallback_dir = share_dir / ".data" / "fallback_logs" + Path(fallback_dir).mkdir(parents=True, exist_ok=True) + log_file = Path(fallback_dir) / f"log_{timestamp}.json" + line_log_file = Path(fallback_dir) / f"log_{timestamp}.log" json_file_handler = logging.FileHandler(log_file) json_file_handler.setLevel(logging.DEBUG) @@ -225,7 +221,7 @@ def setup_file_logging(log_dir: Optional[str] = None) -> Path: return log_file -def extra_data(data: Any) -> Dict[str, Any]: +def extra_data(data: Any) -> dict[str, Any]: """ Helper function to prepare extra data for log messages. @@ -253,4 +249,4 @@ def extra_data(data: Any) -> Dict[str, Any]: # Make extra_data available for import -__all__ = ["setup_file_logging", "extra_data", "logger"] +__all__ = ["extra_data", "logger", "setup_file_logging"] diff --git a/assistants/project-assistant/assistant/notifications.py b/assistants/project-assistant/assistant/notifications.py new file mode 100644 index 000000000..baa0c592f --- /dev/null +++ b/assistants/project-assistant/assistant/notifications.py @@ -0,0 +1,179 @@ +from typing import Any + +from semantic_workbench_api_model.workbench_model import ( + AssistantStateEvent, + MessageType, + NewConversationMessage, +) +from semantic_workbench_assistant.assistant_app import ConversationContext + +from assistant.domain.share_manager import ShareManager + +from .conversation_clients import ConversationClientManager +from .data import InspectorTab +from .logging import logger + + +class Notifications: + @staticmethod + async def notify(context: ConversationContext, message: str, debug_data: dict[str, Any] | None = None) -> None: + """Send text message notification to current conversation only.""" + await context.send_messages( + NewConversationMessage( + content=message, + message_type=MessageType.notice, + debug_data=debug_data, + ) + ) + + @staticmethod + async def notify_self_and_other( + context: ConversationContext, + share_id: str, + message: str, + other_conversation_id: str | None = None, + ) -> None: + """ + Send text message notification to current conversation and one other. + + If called from team conversation: notifies team + coordinator + If called from coordinator: notifies coordinator + specified other_conversation_id + """ + # Always notify current conversation + await Notifications.notify(context, message) + + share = await ShareManager.get_share(context) + if not share: + return + + current_id = str(context.id) + + # Determine the other conversation to notify + if other_conversation_id: + target_id = other_conversation_id + elif share.coordinator_conversation_id and share.coordinator_conversation_id != current_id: + target_id = share.coordinator_conversation_id + else: + return + + try: + client = ConversationClientManager.get_conversation_client(context, target_id) + await client.send_messages( + NewConversationMessage( + content=message, + message_type=MessageType.notice, + ) + ) + except Exception as e: + logger.error(f"Failed to notify conversation {target_id}: {e}") + + @staticmethod + async def notify_all( + context: ConversationContext, share_id: str, message: str, debug_data: dict[str, Any] | None = None + ) -> None: + """Send text message notification to all knowledge transfer conversations.""" + + if debug_data is None: + debug_data = {} + + share = await ShareManager.get_share(context) + if not share: + return + + # Always notify current conversation + await Notifications.notify(context, message) + + current_id = str(context.id) + + # Notify coordinator conversation + if share.coordinator_conversation_id and share.coordinator_conversation_id != current_id: + try: + client = ConversationClientManager.get_conversation_client(context, share.coordinator_conversation_id) + await client.send_messages( + NewConversationMessage( + content=message, + message_type=MessageType.notice, + debug_data=debug_data, + ) + ) + except Exception as e: + logger.error(f"Failed to notify coordinator conversation: {e}") + + # Notify all team conversations + for conv_id in share.team_conversations: + if conv_id != current_id and conv_id != share.coordinator_conversation_id: + try: + client = ConversationClientManager.get_conversation_client(context, conv_id) + await client.send_messages( + NewConversationMessage( + content=message, + message_type=MessageType.notice, + ) + ) + except Exception as e: + logger.error(f"Failed to notify conversation {conv_id}: {e}") + + # State Update Notifications (UI refreshes) + + @staticmethod + async def notify_state_update(context: ConversationContext, tabs: list[InspectorTab]) -> None: + """Send state update notifications to refresh UI in current conversation only.""" + for tab in tabs: + state_event = AssistantStateEvent( + state_id=tab.value, + event="updated", + state=None, + ) + await context.send_conversation_state_event(state_event) + + @staticmethod + async def notify_all_state_update(context: ConversationContext, tabs: list[InspectorTab]) -> None: + """Send state update notifications to refresh UI across all share conversations.""" + + # Refresh current conversation first + await Notifications.notify_state_update(context, tabs) + + # Refresh other conversations + share = await ShareManager.get_share(context) + if not share: + return + + current_id = str(context.id) + assistant_id = context.assistant.id + + # Refresh coordinator conversation + if share.coordinator_conversation_id and share.coordinator_conversation_id != current_id: + try: + client = ConversationClientManager.get_conversation_client(context, share.coordinator_conversation_id) + + for tab in tabs: + state_event = AssistantStateEvent( + state_id=tab.value, + event="updated", + state=None, + ) + await client.send_conversation_state_event( + state_event=state_event, + assistant_id=assistant_id, + ) + except Exception as e: + logger.error(f"Failed to refresh coordinator conversation UI: {e}") + + # Refresh all team conversations + for conv_id in share.team_conversations: + if conv_id != current_id and conv_id != share.coordinator_conversation_id: + try: + client = ConversationClientManager.get_conversation_client(context, conv_id) + + for tab in tabs: + state_event = AssistantStateEvent( + state_id=tab.value, + event="updated", + state=None, + ) + await client.send_conversation_state_event( + state_event=state_event, + assistant_id=assistant_id, + ) + except Exception as e: + logger.error(f"Failed to refresh conversation {conv_id} UI: {e}") diff --git a/assistants/project-assistant/assistant/project_common.py b/assistants/project-assistant/assistant/project_common.py deleted file mode 100644 index 800067ef8..000000000 --- a/assistants/project-assistant/assistant/project_common.py +++ /dev/null @@ -1,80 +0,0 @@ -""" -Common utilities shared between project assistant modes. - -This module provides shared functionality for team and coordinator conversation handlers, -helping to reduce code duplication and maintain consistency. -""" - -from typing import Dict, Optional - -from semantic_workbench_assistant.assistant_app import ConversationContext - -from .conversation_project_link import ConversationProjectManager -from .logging import logger -from .project_data import LogEntryType -from .project_storage import ProjectStorage -from .project_storage_models import ConversationRole - - -async def detect_assistant_role(context: ConversationContext) -> ConversationRole: - """ - Detects whether this conversation is in Coordinator or Team mode. - - This method examines the conversation metadata to determine the role - of the current conversation in the project. The role is always stored - in the conversation metadata as "project_role". - - Args: - context: The conversation context to examine - - Returns: - ConversationRole.COORDINATOR or ConversationRole.TEAM - """ - try: - conversation = await context.get_conversation() - metadata = conversation.metadata or {} - role_str = metadata.get("project_role", "coordinator") - - if role_str == "team": - return ConversationRole.TEAM - else: - return ConversationRole.COORDINATOR - except Exception as e: - logger.exception(f"Error detecting assistant role: {e}") - # Default to coordinator role if we can't determine - return ConversationRole.COORDINATOR - - -async def log_project_action( - context: ConversationContext, - entry_type: LogEntryType, - message: str, - related_entity_id: Optional[str] = None, - additional_metadata: Optional[Dict] = None, -) -> None: - """ - Log an action to the project log. - - This utility function handles retrieving the project ID and logging the event - using the appropriate storage mechanism. It's used by both Coordinator and Team mode - handlers to maintain consistent logging. - - Args: - context: The conversation context - entry_type: Type of log entry - message: Human-readable description of the action - related_entity_id: Optional ID of a related entity (e.g., request ID) - additional_metadata: Optional additional metadata to include in the log - """ - project_id = await ConversationProjectManager.get_associated_project_id(context) - if not project_id: - return - - await ProjectStorage.log_project_event( - context=context, - project_id=project_id, - entry_type=entry_type.value, - message=message, - related_entity_id=related_entity_id, - metadata=additional_metadata, - ) diff --git a/assistants/project-assistant/assistant/project_data.py b/assistants/project-assistant/assistant/project_data.py deleted file mode 100644 index 33d1aec32..000000000 --- a/assistants/project-assistant/assistant/project_data.py +++ /dev/null @@ -1,344 +0,0 @@ -""" -Data models for project entities (briefs, information requests, logs, etc.) - -This module provides the core data structures for the project assistant, -without any artifact abstraction or unnecessary complexity. -""" - -import uuid -from datetime import datetime -from enum import Enum -from typing import Any, Dict, List, Optional - -from pydantic import BaseModel, Field - - -class ProjectState(str, Enum): - """ - States for project progression. - - The project state represents the current phase of the project lifecycle. - Projects follow a standard flow: PLANNING -> READY_FOR_WORKING -> IN_PROGRESS -> COMPLETED. - ABORTED is a terminal state that can be reached from any other state if the project is canceled. - """ - - PLANNING = "planning" # Initial state - Coordinator is defining the project brief and goals - READY_FOR_WORKING = "ready_for_working" # Project is defined and ready for team members to begin work - IN_PROGRESS = "in_progress" # Team members are actively working on the project - COMPLETED = "completed" # Project goals have been achieved and the project is complete - ABORTED = "aborted" # Project was terminated early or canceled - - -class RequestPriority(str, Enum): - """ - Priority levels for information requests. - - Defines the urgency of a request from a team member to the Coordinator. - Higher priority requests should be addressed more quickly by the Coordinator. - MEDIUM is the default priority for most requests. - """ - - LOW = "low" # Non-urgent request, can be addressed when convenient - MEDIUM = "medium" # Standard priority for most requests - HIGH = "high" # Urgent request requiring prompt attention - CRITICAL = "critical" # Highest priority, requires immediate attention from the Coordinator - - -class RequestStatus(str, Enum): - """ - Status options for information requests. - - Represents the lifecycle of an information request from creation to resolution. - Requests typically progress from NEW -> ACKNOWLEDGED -> IN_PROGRESS -> RESOLVED. - DEFERRED indicates requests that will be addressed later. - """ - - NEW = "new" # Request has been created but not yet acknowledged by the Coordinator - ACKNOWLEDGED = "acknowledged" # Coordinator has seen the request but hasn't started working on it - IN_PROGRESS = "in_progress" # Coordinator is actively working on the request - RESOLVED = "resolved" # Coordinator has provided a resolution to the request - DEFERRED = "deferred" # Request handling has been postponed to a later time - - -class LogEntryType(str, Enum): - """ - Types of log entries in the project log. - - These entry types categorize all events that can occur during a project. - Log entries provide a chronological history of actions and events in the project, - allowing both Coordinators and team members to track progress and changes. - """ - - # Brief-related events - BRIEFING_CREATED = "briefing_created" - BRIEFING_UPDATED = "briefing_updated" - - # Goal-related events - GOAL_ADDED = "goal_added" - GOAL_DELETED = "goal_deleted" - - # Information request deletion event - REQUEST_DELETED = "request_deleted" - - # Information request lifecycle events - REQUEST_CREATED = "request_created" - REQUEST_UPDATED = "request_updated" - REQUEST_RESOLVED = "request_resolved" - - # Project state and progress events - STATUS_CHANGED = "status_changed" - GOAL_COMPLETED = "goal_completed" - CRITERION_COMPLETED = "criterion_completed" - - # Participant events - PARTICIPANT_JOINED = "participant_joined" - PARTICIPANT_LEFT = "participant_left" - - # Project lifecycle events - PROJECT_STARTED = "project_started" - PROJECT_COMPLETED = "project_completed" - PROJECT_ABORTED = "project_aborted" - - # Miscellaneous events - MILESTONE_PASSED = "milestone_passed" - INFORMATION_UPDATE = "information_update" - FILE_SHARED = "file_shared" - FILE_DELETED = "file_deleted" - KB_UPDATE = "kb_update" - CUSTOM = "custom" - - -class BaseEntity(BaseModel): - """ - Base class for all project entities. - - Provides common fields and behavior that all project-related data models inherit. - This ensures consistency in how entities are created, versioned, and tracked. - All derived classes will have proper timestamps and creator information. - """ - - version: int = 1 - created_at: datetime = Field(default_factory=datetime.utcnow) - updated_at: datetime = Field(default_factory=datetime.utcnow) - created_by: str # User ID - updated_by: str # User ID - conversation_id: str # Source conversation ID - - -class SuccessCriterion(BaseModel): - """ - A specific measurable criterion that defines project success. - - Success criteria are individual checkpoints that must be completed - to achieve a project goal. Each criterion represents a concrete, - verifiable action or condition that can be marked as completed. - - When all success criteria for all goals are completed, the project - can be considered successful. Team members typically report when - criteria have been met. - """ - - id: str = Field(default_factory=lambda: str(uuid.uuid4())) # Unique identifier for the criterion - description: str # Clear, specific description of what needs to be accomplished - completed: bool = False # Whether this criterion has been met - completed_at: Optional[datetime] = None # When the criterion was marked as completed - completed_by: Optional[str] = None # User ID of the person who completed the criterion - - -class ProjectGoal(BaseModel): - """ - A specific goal for the project with associated success criteria. - - Project goals represent the major objectives that need to be accomplished - for the project to be successful. Each goal consists of a name, description, - priority level, and a list of specific success criteria that define when - the goal can be considered complete. - - Goals are typically set by the Coordinator during project planning and then tracked - by both the Coordinator and team members throughout the project. - """ - - id: str = Field(default_factory=lambda: str(uuid.uuid4())) # Unique identifier for the goal - name: str # Short, clear name of the goal - description: str # Detailed description of what the goal entails - priority: int = 1 # Priority level (1 = highest priority, increasing numbers = lower priority) - success_criteria: List[SuccessCriterion] = Field(default_factory=list) # List of criteria to meet - - -class ProjectBrief(BaseEntity): - """ - A thorough, comprehensive documentation of the project or context to be transferred, - containing all relevant information necessary for understanding and execution. - - The brief is the primary document that defines the project or context. - It serves as the central reference for both the Coordinator and team members - to understand what needs to be accomplished and why, or in the case of context transfer, - what information needs to be preserved and communicated. - - In the standard project configuration, it includes project goals, success criteria, - and complete context. In context transfer configuration, it focuses on capturing - comprehensive context through detailed description and additional_context fields. - - Created by the Coordinator during the PLANNING phase, the brief must be - completed before the project can move to the READY_FOR_WORKING state. - Once team operations begin, the brief can still be updated, - but major changes should be communicated to all participants. - """ - - title: str # Short, distinctive title for the project or context bundle to transfer - description: str # Comprehensive description of the project's or context's purpose, scope, and context - timeline: Optional[str] = None # Expected timeline or deadline information (not used in context transfer mode) - additional_context: Optional[str] = ( - None # Detailed supplementary information for project participants or context transfer - ) - - -class ProjectWhiteboard(BaseEntity): - """ - A dynamic whiteboard that gets automatically updated as the coordinator assembles their project. - - The project whiteboard captures and maintains important project context that emerges during - conversations. It is automatically updated after each assistant message by analyzing - the conversation history and extracting key information. - - Unlike a traditional knowledge base with separate sections, the whiteboard is a single - consolidated view that shows the most relevant information for the project. It serves as - a dynamic, evolving source of truth that all team members can reference. - """ - - content: str = "" # Markdown content for the whiteboard - is_auto_generated: bool = True # Whether the content was auto-generated or manually edited - - -class InformationRequest(BaseEntity): - """ - A specific information need or blocker submitted by team members - that requires Coordinator support to resolve. - - Information requests are the primary communication mechanism for team members - to request assistance, information, or resources from the Coordinator. They represent - questions, blockers, or needs that arise during project execution. - - The lifecycle of an information request typically follows: - 1. Created by a team member (NEW status) - 2. Seen by the Coordinator (ACKNOWLEDGED status) - 3. Worked on by the Coordinator (IN_PROGRESS status) - 4. Completed with a resolution (RESOLVED status) - - Requests can also be DEFERRED for later handling or CANCELLED if no longer relevant. - The request priority helps the Coordinator prioritize which requests to handle first. - """ - - # Request identification - request_id: str = Field(default_factory=lambda: str(uuid.uuid4())) # Unique ID for this request - - # Request details - title: str # Short summary of the request - description: str # Detailed explanation of what is needed - priority: RequestPriority = RequestPriority.MEDIUM # Urgency level of the request - status: RequestStatus = RequestStatus.NEW # Current status in the request lifecycle - - # Reference to the related goal(s) if applicable - related_goal_ids: List[str] = Field(default_factory=list) # IDs of project goals this request relates to - - # Resolution information - resolution: Optional[str] = None # The answer or solution provided by the Coordinator - resolved_at: Optional[datetime] = None # When the request was resolved - resolved_by: Optional[str] = None # User ID of the Coordinator who resolved this request - - # Updates and comments on this request - updates: List[Dict[str, Any]] = Field(default_factory=list) # History of status updates and comments - - -class LogEntry(BaseModel): - """ - Individual entry in the project log. - - Log entries record all significant events that occur during a project. - Each entry has a specific type, message, and associated metadata. - - The chronological sequence of log entries forms a complete audit trail - of the project's progress, actions taken, and events that occurred. - This provides accountability and helps with post-project review. - - Log entries are typically created automatically by the system when - certain actions are taken, but can also be manually added by participants. - """ - - id: str = Field(default_factory=lambda: str(uuid.uuid4())) # Unique identifier for this log entry - timestamp: datetime = Field(default_factory=datetime.utcnow) # When this entry was created - entry_type: LogEntryType # The category/type of this log entry - message: str # Human-readable description of what happened - user_id: str # ID of the user who performed the action or triggered the event - user_name: str # Name of the user, for display purposes - - # Optional additional context for the entry - related_entity_id: Optional[str] = None # ID of related entity (e.g., information request ID) - entity_type: Optional[str] = None # Type of related entity (e.g., "information_request", "goal") - metadata: Optional[Dict] = None # Additional structured data about the event - - -class ProjectLog(BaseModel): - """ - A chronological record of all actions and interactions during the project, - including updates and progress reports. - - The project log serves as the comprehensive history of everything that - happened during a project. It contains a chronological list of log entries - describing actions, state changes, and significant events. - - The log is used for: - - Real-time monitoring of project activity - - Post-project review and analysis - - Accountability and documentation purposes - - Tracking the sequence of events leading to outcomes - - Both the Coordinator and team members can view the project log, providing transparency - into what has occurred during the project. - """ - - entries: List[LogEntry] = Field(default_factory=list) # Chronological list of log entries - - -class ProjectInfo(BaseModel): - """ - Core information about a project. - - This model stores essential project metadata that doesn't fit into other - specific models like brief or whiteboard. It's the central reference point - for project identification, state, and team collaboration settings. - """ - - project_id: str # Unique identifier for the project - state: ProjectState = ProjectState.PLANNING # Current project lifecycle state - coordinator_conversation_id: Optional[str] = None # ID of the coordinator's conversation - team_conversation_id: Optional[str] = None # ID of the team conversation - share_url: Optional[str] = None # Shareable URL for inviting users to the team conversation - created_at: datetime = Field(default_factory=datetime.utcnow) - updated_at: datetime = Field(default_factory=datetime.utcnow) - updated_by: Optional[str] = None # User ID who last updated the project info - status_message: Optional[str] = None # Custom status message about the project - progress_percentage: Optional[int] = None # Current progress percentage (0-100) - next_actions: List[str] = Field(default_factory=list) # List of next actions planned - version: int = 1 # Version counter for tracking changes - completed_criteria: int = 0 # Count of completed success criteria - total_criteria: int = 0 # Total count of success criteria - lifecycle: Dict[str, Any] = Field(default_factory=dict) # Lifecycle metadata - - -class Project(BaseModel): - """ - A comprehensive representation of a project, including its brief, whiteboard, - information requests, logs, and other related entities. - - This model encapsulates all the components that make up a project, - providing a single point of access to all relevant information. - It serves as the main interface for interacting with the project data. - """ - - info: Optional[ProjectInfo] - brief: Optional[ProjectBrief] - goals: List[ProjectGoal] = Field(default_factory=list) - requests: List[InformationRequest] = Field(default_factory=list) - whiteboard: Optional[ProjectWhiteboard] - log: Optional[ProjectLog] = Field(default_factory=lambda: ProjectLog()) diff --git a/assistants/project-assistant/assistant/project_manager.py b/assistants/project-assistant/assistant/project_manager.py deleted file mode 100644 index d4df4bd2f..000000000 --- a/assistants/project-assistant/assistant/project_manager.py +++ /dev/null @@ -1,1296 +0,0 @@ -""" -Project management logic for working with project data. - -This module provides the core business logic for working with project data -""" - -import re -import uuid -from datetime import datetime -from typing import List, Optional, Tuple - -import openai_client -from semantic_workbench_api_model.workbench_model import ( - ConversationPermission, - MessageType, - NewConversation, - NewConversationMessage, - NewConversationShare, - ParticipantRole, -) -from semantic_workbench_assistant.assistant_app import ConversationContext - -from .config import assistant_config -from .conversation_clients import ConversationClientManager -from .conversation_project_link import ConversationProjectManager -from .logging import logger -from .project_data import ( - InformationRequest, - LogEntryType, - Project, - ProjectBrief, - ProjectGoal, - ProjectInfo, - ProjectLog, - ProjectState, - ProjectWhiteboard, - RequestPriority, - RequestStatus, - SuccessCriterion, -) -from .project_notifications import ProjectNotifier -from .project_storage import ProjectStorage, ProjectStorageManager -from .project_storage_models import ConversationRole -from .utils import get_current_user, require_current_user - - -class ProjectManager: - """ - Manages the creation, modification, and lifecycle of projects. - - The ProjectManager provides a centralized set of operations for working with project data. - It handles all the core business logic for interacting with projects, ensuring that - operations are performed consistently and following the proper rules and constraints. - - This class implements the primary interface for both Coordinators and team members to interact - with project entities like briefs, information requests, and knowledge bases. It abstracts - away the storage details and provides a clean API for project operations. - - All methods are implemented as static methods to facilitate easy calling from - different parts of the codebase without requiring instance creation. - """ - - @staticmethod - async def create_shareable_team_conversation(context: ConversationContext, project_id: str) -> str: - """ - Creates a new shareable team conversation template. - - This creates a new conversation owned by the same user as the current conversation, - intended to be used as a shareable team conversation template. This is NOT a - conversation that anyone will directly use. Instead, it's a template that gets - copied when team members redeem the share URL, creating their own individual - team conversations. - - The conversation is tagged with metadata indicating its purpose and gets a - share URL that can be used by team members to join the project. - - Args: - context: Current conversation context - project_id: ID of the project - - Returns: - share_url: URL for joining a team conversation - """ - - # Get the current user ID to set as owner - user_id, _ = await get_current_user(context) - if not user_id: - raise ValueError("Cannot create team conversation: no user found") - - new_conversation = NewConversation( - metadata={ - "is_team_conversation": True, - "project_id": project_id, - "setup_complete": True, - "project_role": "team", - "assistant_mode": "team", - }, - ) - client = context._conversations_client - conversation = await client.create_conversation_with_owner(new_conversation=new_conversation, owner_id=user_id) - - if not conversation or not conversation.id: - raise ValueError("Failed to create team conversation") - - new_share = NewConversationShare( - conversation_id=conversation.id, - label="Join Team Conversation", - conversation_permission=ConversationPermission.read, - metadata={ - "project_id": project_id, - "is_team_conversation": True, - "showDuplicateAction": True, - "show_duplicate_action": True, - }, - ) - share = await context._conversations_client.create_conversation_share_with_owner( - new_conversation_share=new_share, owner_id=user_id - ) - - share_url = f"/conversation-share/{share.id}/redeem" - - # Store team conversation info in ProjectInfo - project_info = ProjectStorage.read_project_info(project_id) - if project_info: - project_info.team_conversation_id = str(conversation.id) - project_info.share_url = share_url - project_info.updated_at = datetime.utcnow() - ProjectStorage.write_project_info(project_id, project_info) - else: - raise ValueError(f"Project info not found for project ID: {project_id}") - - return share_url - - @staticmethod - async def create_project(context: ConversationContext) -> str: - """ - Creates a new project and associates the current conversation with it. - - This is the initial step in project creation. It: - 1. Generates a unique project ID - 2. Associates the current conversation with that project - 3. Sets the current conversation as Coordinator for the project - 4. Creates empty project data structures (brief, whiteboard, etc.) - 5. Logs the project creation event - - After creating a project, the Coordinator should proceed to create a project brief - with specific goals and success criteria. - - Args: - context: Current conversation context containing user/assistant information - - Returns: - Tuple of (success, project_id) where: - - success: Boolean indicating if the creation was successful - - project_id: If successful, the UUID of the newly created project - """ - - # Generate a unique project ID - project_id = str(uuid.uuid4()) - - # Create the project directory structure first - project_dir = ProjectStorageManager.get_project_dir(project_id) - logger.debug(f"Created project directory: {project_dir}") - - # Create and save the initial project info - project_info = ProjectInfo(project_id=project_id, coordinator_conversation_id=str(context.id)) - - # Save the project info - ProjectStorage.write_project_info(project_id, project_info) - logger.debug(f"Created and saved project info: {project_info}") - - # Associate the conversation with the project - logger.debug(f"Associating conversation {context.id} with project {project_id}") - await ConversationProjectManager.associate_conversation_with_project(context, project_id) - - # No need to set conversation role in project storage, as we use metadata - logger.debug(f"Conversation {context.id} is Coordinator for project {project_id}") - - # Ensure linked_conversations directory exists - linked_dir = ProjectStorageManager.get_linked_conversations_dir(project_id) - logger.debug(f"Ensured linked_conversations directory exists: {linked_dir}") - - return project_id - - @staticmethod - async def join_project( - context: ConversationContext, - project_id: str, - role: ConversationRole = ConversationRole.TEAM, - ) -> bool: - """ - Joins an existing project. - - Args: - context: Current conversation context - project_id: ID of the project to join - role: Role for this conversation (COORDINATOR or TEAM) - - Returns: - True if joined successfully, False otherwise - """ - try: - # Check if project exists - if not ProjectStorageManager.project_exists(project_id): - logger.error(f"Cannot join project: project {project_id} does not exist") - return False - - # Associate the conversation with the project - await ConversationProjectManager.associate_conversation_with_project(context, project_id) - - # Role is set in metadata, not in storage - - logger.info(f"Joined project {project_id} as {role.value}") - return True - - except Exception as e: - logger.exception(f"Error joining project: {e}") - return False - - @staticmethod - async def get_project_id(context: ConversationContext) -> Optional[str]: - """ - Gets the project ID associated with the current conversation. - - Every conversation that's part of a project has an associated project ID. - This method retrieves that ID, which is used for accessing project-related - data structures. - - Args: - context: Current conversation context - - Returns: - The project ID string if the conversation is part of a project, None otherwise - """ - return await ConversationProjectManager.get_associated_project_id(context) - - @staticmethod - async def get_project_role(context: ConversationContext) -> Optional[ConversationRole]: - """ - Gets the role of the current conversation in its project. - - Each conversation participating in a project has a specific role: - - COORDINATOR: The primary conversation that created and manages the project - - TEAM: Conversations where team members are carrying out the project tasks - - This method examines the conversation metadata to determine the role - of the current conversation in the project. The role is stored in the - conversation metadata as "project_role". - - Args: - context: Current conversation context - - Returns: - The role (ProjectRole.COORDINATOR or ProjectRole.TEAM) if the conversation - is part of a project, None otherwise - """ - try: - conversation = await context.get_conversation() - metadata = conversation.metadata or {} - role_str = metadata.get("project_role", "coordinator") - - if role_str == "team": - return ConversationRole.TEAM - elif role_str == "coordinator": - return ConversationRole.COORDINATOR - else: - return None - except Exception as e: - logger.exception(f"Error detecting project role: {e}") - # Default to None if we can't determine - return None - - @staticmethod - async def get_project_brief(context: ConversationContext) -> Optional[ProjectBrief]: - """ - Gets the project brief for the current conversation's project. - - The project brief contains the core information about the project: - name, description, goals, and success criteria. This is the central - document that defines what the project is trying to accomplish. - - Args: - context: Current conversation context - - Returns: - The ProjectBrief object if found, None if the conversation is not - part of a project or if no brief has been created yet - """ - project_id = await ProjectManager.get_project_id(context) - if not project_id: - return None - - return ProjectStorage.read_project_brief(project_id) - - @staticmethod - async def update_project_brief( - context: ConversationContext, - title: str, - description: str, - timeline: Optional[str] = None, - additional_context: Optional[str] = None, - send_notification: bool = True, - ) -> Optional[ProjectBrief]: - """ - Creates or updates a project brief for the current project. - - The project brief is the primary document that defines the project for team members. - - Goals should be managed separately through add_project_goal and are not handled by this method. - - Args: - context: A reference to the conversation context object - title: Short, descriptive name for the project - description: Comprehensive description of the project's purpose - timeline: Optional information about project timeline/deadlines - additional_context: Optional additional information relevant to the project - send_notification: Whether to send a notification about the brief update (default: True) - - Returns: - The updated ProjectBrief object if successful, None otherwise - """ - # Get project ID - project_id = await ProjectManager.get_project_id(context) - if not project_id: - logger.error("Cannot update brief: no project associated with this conversation") - return - # Get user information - current_user_id = await require_current_user(context, "update brief") - if not current_user_id: - return - - # Create the project brief - brief = ProjectBrief( - title=title, - description=description, - timeline=timeline, - additional_context=additional_context, - created_by=current_user_id, - updated_by=current_user_id, - conversation_id=str(context.id), - ) - - # Save the brief - ProjectStorage.write_project_brief(project_id, brief) - - # Check if this is a creation or an update - existing_brief = ProjectStorage.read_project_brief(project_id) - if existing_brief: - # This is an update - await ProjectStorage.log_project_event( - context=context, - project_id=project_id, - entry_type=LogEntryType.BRIEFING_UPDATED.value, - message=f"Updated brief: {title}", - ) - else: - # This is a creation - await ProjectStorage.log_project_event( - context=context, - project_id=project_id, - entry_type=LogEntryType.BRIEFING_CREATED.value, - message=f"Created brief: {title}", - ) - - # Only notify if send_notification is True - if send_notification: - # Notify linked conversations - await ProjectNotifier.notify_project_update( - context=context, - project_id=project_id, - update_type="brief", - message=f"Brief created: {title}", - ) - - return brief - - @staticmethod - async def get_project_state( - context: ConversationContext, - ) -> Optional[ProjectState]: - """Gets the project state for the current conversation's project.""" - project_id = await ProjectManager.get_project_id(context) - if not project_id: - return None - - # Get the project info which contains state information - project_info = ProjectStorage.read_project_info(project_id) - if not project_info: - return None - - return project_info.state - - @staticmethod - async def add_project_goal( - context: ConversationContext, - goal_name: str, - goal_description: str, - success_criteria: Optional[List[str]] = None, - priority: int = 1, - ) -> Optional[ProjectGoal]: - """ - Adds a goal to the project. - - Args: - context: Current conversation context - goal_name: Name of the goal - goal_description: Description of the goal - success_criteria: List of success criteria strings (optional) - priority: Priority of the goal (default: 1) - - Returns: - The created ProjectGoal if successful, None otherwise - """ - # Get project ID - project_id = await ProjectManager.get_project_id(context) - if not project_id: - logger.error("Cannot add goal: no project associated with this conversation") - return None - - # Get user information - current_user_id = await require_current_user(context, "add goal") - if not current_user_id: - return None - - # Create success criteria objects if provided - criterion_objects = [] - if success_criteria: - for criterion in success_criteria: - criterion_objects.append(SuccessCriterion(description=criterion)) - - # Create the new goal - new_goal = ProjectGoal( - name=goal_name, - description=goal_description, - priority=priority, - success_criteria=criterion_objects, - ) - - # Get the existing project - project = ProjectStorage.read_project(project_id) - if not project: - # Create a new project if it doesn't exist - project = Project( - info=None, - brief=None, - goals=[new_goal], - whiteboard=None, - requests=[], - ) - else: - # Add the goal to the existing project - project.goals.append(new_goal) - - # Save the updated project - ProjectStorage.write_project(project_id, project) - - # Log the goal addition - await ProjectStorage.log_project_event( - context=context, - project_id=project_id, - entry_type=LogEntryType.GOAL_ADDED.value, - message=f"Added goal: {goal_name}", - ) - - # Notify linked conversations - await ProjectNotifier.notify_project_update( - context=context, - project_id=project_id, - update_type="goal", - message=f"Goal added: {goal_name}", - ) - - return new_goal - - @staticmethod - async def delete_project_goal( - context: ConversationContext, - goal_index: int, - ) -> Tuple[bool, Optional[str]]: - """ - Deletes a goal from the project. - - Args: - context: Current conversation context - goal_index: The index of the goal to delete (0-based) - - Returns: - Tuple of (success, goal_name_or_error_message) - """ - # Get project ID - project_id = await ProjectManager.get_project_id(context) - if not project_id: - logger.error("Cannot delete goal: no project associated with this conversation") - return False, "No project associated with this conversation." - - # Get user information - current_user_id = await require_current_user(context, "delete goal") - if not current_user_id: - return False, "Could not identify current user." - - # Get the existing project - project = ProjectStorage.read_project(project_id) - if not project or not project.goals: - return False, "No project goals found." - - # Validate index - if goal_index < 0 or goal_index >= len(project.goals): - return ( - False, - f"Invalid goal index {goal_index}. Valid indexes are 0 to {len(project.goals) - 1}. There are {len(project.goals)} goals.", - ) - - # Get the goal to delete - goal = project.goals[goal_index] - goal_name = goal.name - - # Remove the goal from the list - project.goals.pop(goal_index) - - # Save the updated project - ProjectStorage.write_project(project_id, project) - - # Log the goal deletion - await ProjectStorage.log_project_event( - context=context, - project_id=project_id, - entry_type=LogEntryType.GOAL_DELETED.value, - message=f"Deleted goal: {goal_name}", - ) - - # Notify linked conversations - await ProjectNotifier.notify_project_update( - context=context, - project_id=project_id, - update_type="goal", - message=f"Goal deleted: {goal_name}", - ) - - # Update project info with new criteria counts - project_info = ProjectStorage.read_project_info(project_id) - if project_info: - # Count all completed criteria - completed_criteria = 0 - total_criteria = 0 - - # Get the updated project to access goals - updated_project = ProjectStorage.read_project(project_id) - if updated_project and updated_project.goals: - for g in updated_project.goals: - total_criteria += len(g.success_criteria) - completed_criteria += sum(1 for c in g.success_criteria if c.completed) - - # Update project info with criteria stats - project_info.completed_criteria = completed_criteria - project_info.total_criteria = total_criteria - - # Calculate progress percentage - if total_criteria > 0: - project_info.progress_percentage = int((completed_criteria / total_criteria) * 100) - else: - project_info.progress_percentage = 0 - - # Update metadata - project_info.updated_at = datetime.utcnow() - project_info.updated_by = current_user_id - project_info.version += 1 - - # Save the updated project info - ProjectStorage.write_project_info(project_id, project_info) - - # Update all project UI inspectors - await ProjectStorage.refresh_all_project_uis(context, project_id) - - return True, goal_name - - @staticmethod - async def get_project_criteria(context: ConversationContext) -> List[SuccessCriterion]: - """ - Gets the success criteria for the current conversation's project. - - Args: - context: Current conversation context - completed_only: If True, only return completed criteria - - Returns: - List of SuccessCriterion objects - """ - project_id = await ProjectManager.get_project_id(context) - if not project_id: - return [] - - # Get the project which contains goals and success criteria - project = ProjectStorage.read_project(project_id) - if not project: - return [] - - goals = project.goals - criteria = [] - for goal in goals: - # Add success criteria from each goal - criteria.extend(goal.success_criteria) - - return criteria - - @staticmethod - async def update_project_info( - context: ConversationContext, - state: Optional[str] = None, - progress: Optional[int] = None, - status_message: Optional[str] = None, - next_actions: Optional[List[str]] = None, - ) -> Optional[ProjectInfo]: - """ - Updates the project info with state, progress, status message, and next actions. - - Args: - context: Current conversation context - state: Optional project state - progress: Optional progress percentage (0-100) - status_message: Optional status message - next_actions: Optional list of next actions - - Returns: - Tuple of (success, project_info) - """ - # Get project ID - project_id = await ProjectManager.get_project_id(context) - if not project_id: - logger.error("Cannot update project info: no project associated with this conversation") - return None - - # Get user information - current_user_id = await require_current_user(context, "update project info") - if not current_user_id: - return None - - # Get existing project info - project_info = ProjectStorage.read_project_info(project_id) - if not project_info: - logger.error(f"Cannot update project info: no project info found for {project_id}") - return None - - # Apply updates - if state: - project_info.state = ProjectState(state) - - if status_message: - project_info.status_message = status_message - - if progress is not None: - project_info.progress_percentage = progress - - if next_actions: - if not hasattr(project_info, "next_actions"): - project_info.next_actions = [] - project_info.next_actions = next_actions - - # Update metadata - project_info.updated_at = datetime.utcnow() - project_info.updated_by = current_user_id - - # Increment version if it exists - if hasattr(project_info, "version"): - project_info.version += 1 - - # Save the project info - ProjectStorage.write_project_info(project_id, project_info) - - # Log the update - event_type = LogEntryType.STATUS_CHANGED - message = f"Updated project status to {project_info.state.value}" - if progress is not None: - message += f" ({progress}% complete)" - - await ProjectStorage.log_project_event( - context=context, - project_id=project_id, - entry_type=event_type.value, - message=message, - metadata={ - "state": project_info.state.value, - "status_message": status_message, - "progress": progress, - }, - ) - - # Notify linked conversations - await ProjectNotifier.notify_project_update( - context=context, - project_id=project_id, - update_type="project_info", - message=f"Project status updated: {project_info.state.value}", - ) - - return project_info - - @staticmethod - async def update_project_state( - context: ConversationContext, - state: Optional[str] = None, - status_message: Optional[str] = None, - ) -> Tuple[bool, Optional[ProjectInfo]]: - """ - Updates the project state and status message. - - Args: - context: Current conversation context - state: Optional project state - status_message: Optional status message - - Returns: - Tuple of (success, project_info) - """ - try: - # Get project ID - project_id = await ProjectManager.get_project_id(context) - if not project_id: - logger.error("Cannot update project state: no project associated with this conversation") - return False, None - - # Get user information - current_user_id = await require_current_user(context, "update project state") - if not current_user_id: - return False, None - - # Get existing project info - project_info = ProjectStorage.read_project_info(project_id) - if not project_info: - logger.error(f"Cannot update project state: no project info found for {project_id}") - return False, None - - # Apply updates - if state: - project_info.state = ProjectState(state) - - if status_message: - project_info.status_message = status_message - - # Update metadata - project_info.updated_at = datetime.utcnow() - - # Save the project info - ProjectStorage.write_project_info(project_id, project_info) - - # Log the update - event_type = LogEntryType.STATUS_CHANGED - message = f"Updated project state to {project_info.state.value}" - - await ProjectStorage.log_project_event( - context=context, - project_id=project_id, - entry_type=event_type.value, - message=message, - metadata={ - "state": project_info.state.value, - "status_message": status_message, - }, - ) - - # Notify linked conversations - await ProjectNotifier.notify_project_update( - context=context, - project_id=project_id, - update_type="project_state", - message=f"Project state updated: {project_info.state.value}", - ) - - return True, project_info - - except Exception as e: - logger.exception(f"Error updating project state: {e}") - return False, None - - @staticmethod - async def get_information_requests( - context: ConversationContext, - ) -> List[InformationRequest]: - """Gets all information requests for the current conversation's project.""" - project_id = await ProjectManager.get_project_id(context) - if not project_id: - return [] - - return ProjectStorage.get_all_information_requests(project_id) - - @staticmethod - async def create_information_request( - context: ConversationContext, - title: str, - description: str, - priority: RequestPriority = RequestPriority.MEDIUM, - related_goal_ids: Optional[List[str]] = None, - ) -> Tuple[bool, Optional[InformationRequest]]: - """ - Creates a new information request. - - Args: - context: Current conversation context - title: Title of the request - description: Description of the request - priority: Priority level - related_goal_ids: Optional list of related goal IDs - - Returns: - Tuple of (success, information_request) - """ - try: - # Get project ID - project_id = await ProjectManager.get_project_id(context) - if not project_id: - logger.error("Cannot create information request: no project associated with this conversation") - return False, None - - # Get user information - current_user_id = await require_current_user(context, "create information request") - if not current_user_id: - return False, None - - # Create the information request - information_request = InformationRequest( - title=title, - description=description, - priority=priority, - related_goal_ids=related_goal_ids or [], - created_by=current_user_id, - updated_by=current_user_id, - conversation_id=str(context.id), - ) - - # Save the request - ProjectStorage.write_information_request(project_id, information_request) - - # Log the creation - await ProjectStorage.log_project_event( - context=context, - project_id=project_id, - entry_type=LogEntryType.REQUEST_CREATED.value, - message=f"Created information request: {title}", - related_entity_id=information_request.request_id, - metadata={ - "priority": priority.value, - "request_id": information_request.request_id, - }, - ) - - # For high priority requests, we could update project info or add an indicator - # in the future if needed - - # Notify linked conversations - await ProjectNotifier.notify_project_update( - context=context, - project_id=project_id, - update_type="information_request", - message=f"New information request: {title} (Priority: {priority.value})", - ) - - # Update all project UI inspectors - await ProjectStorage.refresh_all_project_uis(context, project_id) - - return True, information_request - - except Exception as e: - logger.exception(f"Error creating information request: {e}") - return False, None - - @staticmethod - async def resolve_information_request( - context: ConversationContext, - request_id: str, - resolution: str, - ) -> Tuple[bool, Optional[InformationRequest]]: - """ - Resolves an information request. - - Args: - context: Current conversation context - request_id: ID of the request to resolve - resolution: Resolution information - - Returns: - Tuple of (success, information_request) - """ - try: - # Get project ID - project_id = await ProjectManager.get_project_id(context) - if not project_id: - logger.error("Cannot resolve information request: no project associated with this conversation") - return False, None - - # Get user information - current_user_id = await require_current_user(context, "resolve information request") - if not current_user_id: - return False, None - - # Get the information request - information_request = ProjectStorage.read_information_request(project_id, request_id) - if not information_request: - # Try to find it in all requests - all_requests = ProjectStorage.get_all_information_requests(project_id) - for request in all_requests: - if request.request_id == request_id: - information_request = request - break - - if not information_request: - logger.error(f"Information request {request_id} not found") - return False, None - - # Check if already resolved - if information_request.status == RequestStatus.RESOLVED: - logger.warning(f"Information request {request_id} is already resolved") - return True, information_request - - # Update the request - information_request.status = RequestStatus.RESOLVED - information_request.resolution = resolution - information_request.resolved_at = datetime.utcnow() - information_request.resolved_by = current_user_id - - # Add to history - information_request.updates.append({ - "timestamp": datetime.utcnow().isoformat(), - "user_id": current_user_id, - "message": f"Request resolved: {resolution}", - "status": RequestStatus.RESOLVED.value, - }) - - # Update metadata - information_request.updated_at = datetime.utcnow() - information_request.updated_by = current_user_id - information_request.version += 1 - - # Save the updated request - ProjectStorage.write_information_request(project_id, information_request) - - # Log the resolution - await ProjectStorage.log_project_event( - context=context, - project_id=project_id, - entry_type=LogEntryType.REQUEST_RESOLVED.value, - message=f"Resolved information request: {information_request.title}", - related_entity_id=information_request.request_id, - metadata={ - "resolution": resolution, - "request_title": information_request.title, - "request_priority": information_request.priority.value - if hasattr(information_request.priority, "value") - else information_request.priority, - }, - ) - - # High priority request has been resolved, could update project info - # in the future if needed - - # Notify linked conversations - await ProjectNotifier.notify_project_update( - context=context, - project_id=project_id, - update_type="information_request_resolved", - message=f"Information request resolved: {information_request.title}", - ) - - # Send direct notification to requestor's conversation - if information_request.conversation_id != str(context.id): - try: - # Get client for requestor's conversation - client = ConversationClientManager.get_conversation_client( - context, information_request.conversation_id - ) - - # Send notification message - await client.send_messages( - NewConversationMessage( - content=f"Coordinator has resolved your request '{information_request.title}': {resolution}", - message_type=MessageType.notice, - ) - ) - except Exception as e: - logger.warning(f"Could not send notification to requestor: {e}") - - # Update all project UI inspectors - await ProjectStorage.refresh_all_project_uis(context, project_id) - - return True, information_request - - except Exception as e: - logger.exception(f"Error resolving information request: {e}") - return False, None - - @staticmethod - async def get_project_log(context: ConversationContext) -> Optional[ProjectLog]: - """Gets the project log for the current conversation's project.""" - project_id = await ProjectManager.get_project_id(context) - if not project_id: - return None - - return ProjectStorage.read_project_log(project_id) - - @staticmethod - async def get_project(context: ConversationContext) -> Optional[Project]: - """Gets the project information for the current conversation's project.""" - project_id = await ProjectManager.get_project_id(context) - if not project_id: - return None - project = Project( - info=ProjectStorage.read_project_info(project_id), - brief=ProjectStorage.read_project_brief(project_id), - whiteboard=ProjectStorage.read_project_whiteboard(project_id), - requests=ProjectStorage.get_all_information_requests(project_id), - log=ProjectStorage.read_project_log(project_id), - ) - return project - - @staticmethod - async def get_project_info(context: ConversationContext, project_id: Optional[str] = None) -> Optional[ProjectInfo]: - """ - Gets the project information including share URL and team conversation details. - - Args: - context: Current conversation context - project_id: Optional project ID (if not provided, will be retrieved from context) - - Returns: - ProjectInfo object or None if not found - """ - try: - # Get project ID if not provided - if not project_id: - project_id = await ProjectManager.get_project_id(context) - if not project_id: - return None - - # Read project info - project_info = ProjectStorage.read_project_info(project_id) - return project_info - - except Exception as e: - logger.exception(f"Error getting project info: {e}") - return None - - @staticmethod - async def get_project_whiteboard( - context: ConversationContext, - ) -> Optional[ProjectWhiteboard]: - """Gets the project whiteboard for the current conversation's project.""" - project_id = await ProjectManager.get_project_id(context) - if not project_id: - return None - - return ProjectStorage.read_project_whiteboard(project_id) - - @staticmethod - async def update_whiteboard( - context: ConversationContext, - content: str, - is_auto_generated: bool = True, - send_notification: bool = False, # Add parameter to control notifications - ) -> Tuple[bool, Optional[ProjectWhiteboard]]: - """ - Updates the project whiteboard content. - - Args: - context: Current conversation context - content: Whiteboard content in markdown format - is_auto_generated: Whether the content was automatically generated - send_notification: Whether to send notifications about the update (default: False) - - Returns: - Tuple of (success, project_kb) - """ - try: - # Get project ID - project_id = await ProjectManager.get_project_id(context) - if not project_id: - logger.error("Cannot update whiteboard: no project associated with this conversation") - return False, None - - # Get user information - current_user_id = await require_current_user(context, "update whiteboard") - if not current_user_id: - return False, None - - # Get existing whiteboard or create new one - whiteboard = ProjectStorage.read_project_whiteboard(project_id) - is_new = False - - if not whiteboard: - whiteboard = ProjectWhiteboard( - created_by=current_user_id, - updated_by=current_user_id, - conversation_id=str(context.id), - content="", - ) - is_new = True - - # Update the content - whiteboard.content = content - whiteboard.is_auto_generated = is_auto_generated - - # Update metadata - whiteboard.updated_at = datetime.utcnow() - whiteboard.updated_by = current_user_id - whiteboard.version += 1 - - # Save the whiteboard - ProjectStorage.write_project_whiteboard(project_id, whiteboard) - - # Log the update - event_type = LogEntryType.KB_UPDATE - update_type = "auto-generated" if is_auto_generated else "manual" - message = f"{'Created' if is_new else 'Updated'} project whiteboard ({update_type})" - - await ProjectStorage.log_project_event( - context=context, - project_id=project_id, - entry_type=event_type.value, - message=message, - ) - - # Only notify linked conversations if explicitly requested - # This prevents auto-updates from generating notifications - if send_notification: - await ProjectNotifier.notify_project_update( - context=context, - project_id=project_id, - update_type="project_whiteboard", - message="Project whiteboard updated", - ) - else: - # Just refresh the UI without sending notifications - await ProjectStorage.refresh_all_project_uis(context, project_id) - - return True, whiteboard - - except Exception as e: - logger.exception(f"Error updating whiteboard: {e}") - return False, None - - @staticmethod - async def auto_update_whiteboard( - context: ConversationContext, - ) -> Tuple[bool, Optional[ProjectWhiteboard]]: - """ - Automatically updates the whiteboard by analyzing chat history. - - This method: - 1. Retrieves recent conversation messages - 2. Sends them to the LLM with a prompt to extract important info - 3. Updates the whiteboard with the extracted content - - Args: - context: Current conversation context - chat_history: Recent chat messages to analyze - - Returns: - Tuple of (success, project_kb) - """ - try: - messages = await context.get_messages() - chat_history = messages.messages - - # Get project ID - project_id = await ProjectManager.get_project_id(context) - if not project_id: - logger.error("Cannot auto-update whiteboard: no project associated with this conversation") - return False, None - - # Get user information for storage purposes - current_user_id = await require_current_user(context, "auto-update whiteboard") - if not current_user_id: - return False, None - - # Skip if no messages to analyze - if not chat_history: - logger.warning("No chat history to analyze for whiteboard update") - return False, None - - # Format the chat history for the prompt - chat_history_text = "" - for msg in chat_history: - sender_type = ( - "User" if msg.sender and msg.sender.participant_role == ParticipantRole.user else "Assistant" - ) - chat_history_text += f"{sender_type}: {msg.content}\n\n" - - # Get config for the LLM call - config = await assistant_config.get(context.assistant) - - # Construct the whiteboard prompt with the chat history - whiteboard_prompt = f""" - {config.prompt_config.whiteboard_prompt} - - <CHAT_HISTORY> - {chat_history_text} - </CHAT_HISTORY> - """ - - # Create a completion with the whiteboard prompt - async with openai_client.create_client(config.service_config, api_version="2024-06-01") as client: - completion = await client.chat.completions.create( - model=config.request_config.openai_model, - messages=[{"role": "user", "content": whiteboard_prompt}], - max_tokens=2500, # Limiting to 2500 tokens to keep whiteboard content manageable - ) - - # Extract the content from the completion - content = completion.choices[0].message.content or "" - - # Extract just the whiteboard content - whiteboard_content = "" - - # Look for content between <WHITEBOARD> tags - match = re.search(r"<WHITEBOARD>(.*?)</WHITEBOARD>", content, re.DOTALL) - if match: - whiteboard_content = match.group(1).strip() - else: - # If no tags, use the whole content - whiteboard_content = content.strip() - - # Only update if we have content - if not whiteboard_content: - logger.warning("No content extracted from whiteboard LLM analysis") - return False, None - - # Update the whiteboard with the extracted content - # Use send_notification=False to avoid sending notifications for automatic updates - return await ProjectManager.update_whiteboard( - context=context, - content=whiteboard_content, - is_auto_generated=True, - send_notification=False, - ) - - except Exception as e: - logger.exception(f"Error auto-updating whiteboard: {e}") - return False, None - - @staticmethod - async def complete_project( - context: ConversationContext, - summary: Optional[str] = None, - ) -> Tuple[bool, Optional[ProjectInfo]]: - """ - Completes a project and updates the project state. - - Args: - context: Current conversation context - summary: Optional summary of project results - - Returns: - Tuple of (success, project_info) - """ - try: - # Get project ID - project_id = await ProjectManager.get_project_id(context) - if not project_id: - logger.error("Cannot complete project: no project associated with this conversation") - return False, None - - # Get role - only Coordinator can complete a project - role = await ProjectManager.get_project_role(context) - if role != ConversationRole.COORDINATOR: - logger.error("Only Coordinator can complete a project") - return False, None - - # Update project state to completed - status_message = summary if summary else "Project completed successfully" - success, project_info = await ProjectManager.update_project_state( - context=context, - state=ProjectState.COMPLETED.value, - status_message=status_message, - ) - - if not success or not project_info: - return False, None - - # Add completion entry to the log - await ProjectStorage.log_project_event( - context=context, - project_id=project_id, - entry_type=LogEntryType.PROJECT_COMPLETED.value, - message=f"Project completed: {status_message}", - ) - - # Notify linked conversations with emphasis - await ProjectNotifier.notify_project_update( - context=context, - project_id=project_id, - update_type="project_completed", - message=f"🎉 PROJECT COMPLETED: {status_message}", - ) - - return True, project_info - - except Exception as e: - logger.exception(f"Error completing project: {e}") - return False, None diff --git a/assistants/project-assistant/assistant/project_notifications.py b/assistants/project-assistant/assistant/project_notifications.py deleted file mode 100644 index 48969e034..000000000 --- a/assistants/project-assistant/assistant/project_notifications.py +++ /dev/null @@ -1,224 +0,0 @@ -""" -Project notification and UI refresh functionality. - -This module handles notifications between conversations and UI refresh events -for the project assistant, ensuring all participants stay in sync. -""" - -from typing import Any, Dict, Optional - -from semantic_workbench_api_model.workbench_model import AssistantStateEvent, MessageType, NewConversationMessage -from semantic_workbench_assistant.assistant_app import ConversationContext - -from .logging import logger -from .project_storage import ProjectStorage - - -class ProjectNotifier: - """Handles notifications between conversations for project updates.""" - - @staticmethod - async def send_notice_to_linked_conversations(context: ConversationContext, project_id: str, message: str) -> None: - """ - Sends a notice message to all linked conversations except: - 1. The current conversation - 2. The shareable team conversation template (used only for creating the share URL) - - NOTE: The shareable team conversation is NEVER used directly by any user. - It's just a template that gets copied when team members redeem the share URL - to create their own individual team conversations. We exclude it from notifications - because no one will ever see those notifications. - - This method does NOT refresh any UI inspector panels. - - Args: - context: Current conversation context - project_id: ID of the project - message: Notification message to send - """ - # Import ConversationClientManager locally to avoid circular imports - from .conversation_clients import ConversationClientManager - from .conversation_project_link import ConversationProjectManager - - # Get conversation IDs in the same project - linked_conversations = await ConversationProjectManager.get_linked_conversations(context) - current_conversation_id = str(context.id) - - # Get the shareable team conversation ID from project info - # This is the conversation created by the coordinator for sharing, - # not an actual user conversation - shareable_conversation_id = None - project_info = ProjectStorage.read_project_info(project_id) - if project_info and project_info.team_conversation_id: - shareable_conversation_id = project_info.team_conversation_id - - # Send notification to each linked conversation, excluding current and shareable conversation - for conv_id in linked_conversations: - # Skip current conversation and the shareable team conversation - if conv_id != current_conversation_id and ( - not shareable_conversation_id or conv_id != shareable_conversation_id - ): - try: - # Get client for the target conversation - client = ConversationClientManager.get_conversation_client(context, conv_id) - - # Send the notification - await client.send_messages( - NewConversationMessage( - content=message, - message_type=MessageType.notice, - metadata={ - "debug": { - "project_id": project_id, - "message": message, - "sender": str(context.id), - } - }, - ) - ) - logger.debug(f"Sent notification to conversation {conv_id}") - except Exception as e: - logger.error(f"Failed to notify conversation {conv_id}: {e}") - - @staticmethod - async def notify_project_update( - context: ConversationContext, - project_id: str, - update_type: str, - message: str, - data: Optional[Dict[str, Any]] = None, - send_notification: bool = True, # Add parameter to control notifications - ) -> None: - """ - Complete project update: sends notices to all conversations and refreshes all UI inspector panels. - - This method: - 1. Sends a notice message to the current conversation (if send_notification=True) - 2. Sends the same notice message to all linked conversations (if send_notification=True) - 3. Refreshes UI inspector panels for all conversations in the project - - Use this for important project updates that need both user notification AND UI refresh. - Set send_notification=False for frequent updates (like file syncs, whiteboard updates) to - avoid notification spam. - - Args: - context: Current conversation context - project_id: ID of the project - update_type: Type of update (e.g., 'brief', 'project_info', 'information_request', etc.) - message: Notification message to display to users - data: Optional additional data related to the update - send_notification: Whether to send notifications (default: True) - """ - - # Only send notifications if explicitly requested - if send_notification: - # Notify all linked conversations with the same message - await ProjectNotifier.send_notice_to_linked_conversations(context, project_id, message) - - # Always refresh all project UI inspector panels to keep UI in sync - # This will update the UI without sending notifications - await ProjectStorage.refresh_all_project_uis(context, project_id) - - -async def refresh_current_ui(context: ConversationContext) -> None: - """ - Refreshes only the current conversation's UI inspector panel. - - Use this when a change only affects the local conversation's view - and doesn't need to be synchronized with other conversations. - """ - - # Create the state event - state_event = AssistantStateEvent( - state_id="project_status", # Must match the inspector_state_providers key in chat.py - event="updated", - state=None, - ) - - # Send the event to the current context - await context.send_conversation_state_event(state_event) - - -async def refresh_all_project_uis(context: ConversationContext, project_id: str) -> None: - """ - Refreshes the UI inspector panels of all conversations in a project except the - shareable team conversation template. - - There are three types of conversations in the system: - 1. Coordinator Conversation - The main conversation for the project owner - 2. Shareable Team Conversation Template - Only used to generate the share URL, never directly used by any user - 3. Team Conversation(s) - Individual conversations for each team member - - This sends a state event to all relevant conversations (Coordinator and all active team members) - involved in the project to refresh their inspector panels, ensuring all - participants have the latest information without sending any text notifications. - - The shareable team conversation template is excluded because no user will ever see it - - it only exists to create the share URL that team members can use to join. - - Use this when project data has changed and all UIs need to be updated, - but you don't want to send notification messages to users. - - Args: - context: Current conversation context - project_id: The project ID - """ - # Import ConversationClientManager locally to avoid circular imports - from .conversation_clients import ConversationClientManager - from .conversation_project_link import ConversationProjectManager - - try: - # First update the current conversation's UI - await refresh_current_ui(context) - - # Get the shareable team conversation ID from project info to exclude it - shareable_conversation_id = None - project_info = ProjectStorage.read_project_info(project_id) - if project_info and project_info.team_conversation_id: - shareable_conversation_id = project_info.team_conversation_id - - # Get Coordinator client and update Coordinator if not the current conversation - ( - coordinator_client, - coordinator_conversation_id, - ) = await ConversationClientManager.get_coordinator_client_for_project(context, project_id) - if coordinator_client and coordinator_conversation_id: - try: - state_event = AssistantStateEvent(state_id="project_status", event="updated", state=None) - # Get assistant ID from context - assistant_id = context.assistant.id - await coordinator_client.send_conversation_state_event(assistant_id, state_event) - logger.debug( - f"Sent state event to Coordinator conversation {coordinator_conversation_id} to refresh inspector" - ) - except Exception as e: - logger.warning(f"Error sending state event to Coordinator: {e}") - - # Get all team conversation clients and update them - linked_conversations = await ConversationProjectManager.get_linked_conversations(context) - current_id = str(context.id) - - for conv_id in linked_conversations: - # Skip current conversation, coordinator conversation, and shareable conversation - if ( - conv_id != current_id - and (not coordinator_conversation_id or conv_id != coordinator_conversation_id) - and (not shareable_conversation_id or conv_id != shareable_conversation_id) - ): - try: - # Get client for the conversation - client = ConversationClientManager.get_conversation_client(context, conv_id) - - # Send state event to refresh the inspector panel - state_event = AssistantStateEvent(state_id="project_status", event="updated", state=None) - # Get assistant ID from context - assistant_id = context.assistant.id - await client.send_conversation_state_event(assistant_id, state_event) - except Exception as e: - logger.warning(f"Error sending state event to conversation {conv_id}: {e}") - continue - elif conv_id == shareable_conversation_id: - logger.info(f"Skipping UI update for shareable conversation: {conv_id}") - - except Exception as e: - logger.warning(f"Error notifying all project UIs: {e}") diff --git a/assistants/project-assistant/assistant/project_storage.py b/assistants/project-assistant/assistant/project_storage.py deleted file mode 100644 index b7f7e606d..000000000 --- a/assistants/project-assistant/assistant/project_storage.py +++ /dev/null @@ -1,373 +0,0 @@ -""" -Project storage management module. - -Provides direct access to project data with a clean, simple storage approach. -""" - -import pathlib -from datetime import datetime -from typing import Any, Dict, List, Optional - -from semantic_workbench_assistant import settings -from semantic_workbench_assistant.assistant_app import ConversationContext -from semantic_workbench_assistant.assistant_app.context import storage_directory_for_context -from semantic_workbench_assistant.storage import read_model, write_model - -# Import inside functions to avoid circular imports -from .project_data import ( - InformationRequest, - LogEntry, - LogEntryType, - Project, - ProjectBrief, - ProjectInfo, - ProjectLog, - ProjectWhiteboard, -) -from .project_storage_models import CoordinatorConversationMessage, CoordinatorConversationStorage -from .utils import get_current_user - - -class ProjectStorageManager: - """Manages storage paths and access for project data.""" - - PROJECTS_ROOT = "projects" - - # File names for project entities - PROJECT_INFO_FILE = "project.json" - PROJECT_BRIEF_FILE = "brief.json" - PROJECT_LOG_FILE = "log.json" - PROJECT_WHITEBOARD_FILE = "whiteboard.json" - COORDINATOR_CONVERSATION_FILE = "coordinator_conversation.json" - PROJECT_FILE = "project_data.json" - - @staticmethod - def get_projects_root() -> pathlib.Path: - """Gets the root path for all projects.""" - return pathlib.Path(settings.storage.root) / ProjectStorageManager.PROJECTS_ROOT - - @staticmethod - def get_project_dir(project_id: str) -> pathlib.Path: - """Gets the directory for a specific project.""" - projects_root = ProjectStorageManager.get_projects_root() - project_dir = projects_root / project_id - project_dir.mkdir(parents=True, exist_ok=True) - return project_dir - - @staticmethod - def get_linked_conversations_dir(project_id: str) -> pathlib.Path: - """Gets the directory that tracks linked conversations for a project.""" - project_dir = ProjectStorageManager.get_project_dir(project_id) - linked_dir = project_dir / "linked_conversations" - linked_dir.mkdir(parents=True, exist_ok=True) - return linked_dir - - @staticmethod - def get_project_info_path(project_id: str) -> pathlib.Path: - """Gets the path to the project info file.""" - project_dir = ProjectStorageManager.get_project_dir(project_id) - return project_dir / ProjectStorageManager.PROJECT_INFO_FILE - - @staticmethod - def get_brief_path(project_id: str) -> pathlib.Path: - """Gets the path to the project brief file.""" - project_dir = ProjectStorageManager.get_project_dir(project_id) - return project_dir / ProjectStorageManager.PROJECT_BRIEF_FILE - - @staticmethod - def get_project_log_path(project_id: str) -> pathlib.Path: - """Gets the path to the project log file.""" - project_dir = ProjectStorageManager.get_project_dir(project_id) - return project_dir / ProjectStorageManager.PROJECT_LOG_FILE - - @staticmethod - def get_project_whiteboard_path(project_id: str) -> pathlib.Path: - """Gets the path to the project whiteboard file.""" - project_dir = ProjectStorageManager.get_project_dir(project_id) - return project_dir / ProjectStorageManager.PROJECT_WHITEBOARD_FILE - - @staticmethod - def get_coordinator_conversation_path(project_id: str) -> pathlib.Path: - """Gets the path to the Coordinator conversation file.""" - project_dir = ProjectStorageManager.get_project_dir(project_id) - return project_dir / ProjectStorageManager.COORDINATOR_CONVERSATION_FILE - - @staticmethod - def get_project_path(project_id: str) -> pathlib.Path: - """Gets the path to the complete Project data file.""" - project_dir = ProjectStorageManager.get_project_dir(project_id) - return project_dir / ProjectStorageManager.PROJECT_FILE - - @staticmethod - def get_information_requests_dir(project_id: str) -> pathlib.Path: - """Gets the directory containing all information requests.""" - project_dir = ProjectStorageManager.get_project_dir(project_id) - requests_dir = project_dir / "requests" - requests_dir.mkdir(parents=True, exist_ok=True) - return requests_dir - - @staticmethod - def get_information_request_path(project_id: str, request_id: str) -> pathlib.Path: - """Gets the path to an information request file.""" - requests_dir = ProjectStorageManager.get_information_requests_dir(project_id) - return requests_dir / f"{request_id}.json" - - @staticmethod - def project_exists(project_id: str) -> bool: - """Checks if a project exists.""" - project_dir = ProjectStorageManager.get_projects_root() / project_id - return project_dir.exists() - - @staticmethod - def get_conversation_role_file_path(context: ConversationContext) -> pathlib.Path: - """Gets the path to the file that stores a conversation's role in projects.""" - storage_dir = storage_directory_for_context(context) - storage_dir.mkdir(parents=True, exist_ok=True) - return storage_dir / "project_role.json" - - @staticmethod - def get_conversation_project_file_path(context: ConversationContext) -> pathlib.Path: - """Gets the path to the file that stores a conversation's project association.""" - storage_dir = storage_directory_for_context(context) - storage_dir.mkdir(parents=True, exist_ok=True) - file_path = storage_dir / "project_association.json" - return file_path - - -class ProjectStorage: - """Unified storage operations for project data.""" - - @staticmethod - def read_project_info(project_id: str) -> Optional[ProjectInfo]: - """Reads the project info.""" - path = ProjectStorageManager.get_project_info_path(project_id) - return read_model(path, ProjectInfo) - - @staticmethod - def write_project_info(project_id: str, info: ProjectInfo) -> pathlib.Path: - """Writes the project info.""" - path = ProjectStorageManager.get_project_info_path(project_id) - write_model(path, info) - return path - - @staticmethod - def read_project_brief(project_id: str) -> Optional[ProjectBrief]: - """Reads the project brief.""" - path = ProjectStorageManager.get_brief_path(project_id) - return read_model(path, ProjectBrief) - - @staticmethod - def write_project_brief(project_id: str, brief: ProjectBrief) -> pathlib.Path: - """Writes the project brief.""" - path = ProjectStorageManager.get_brief_path(project_id) - write_model(path, brief) - return path - - @staticmethod - def read_project_log(project_id: str) -> Optional[ProjectLog]: - """Reads the project log.""" - path = ProjectStorageManager.get_project_log_path(project_id) - return read_model(path, ProjectLog) - - @staticmethod - def write_project_log(project_id: str, log: ProjectLog) -> pathlib.Path: - """Writes the project log.""" - path = ProjectStorageManager.get_project_log_path(project_id) - write_model(path, log) - return path - - @staticmethod - def read_project_whiteboard(project_id: str) -> Optional[ProjectWhiteboard]: - """Reads the project whiteboard.""" - path = ProjectStorageManager.get_project_whiteboard_path(project_id) - return read_model(path, ProjectWhiteboard) - - @staticmethod - def read_coordinator_conversation(project_id: str) -> Optional[CoordinatorConversationStorage]: - """Reads the Coordinator conversation messages for a project.""" - path = ProjectStorageManager.get_coordinator_conversation_path(project_id) - return read_model(path, CoordinatorConversationStorage) - - @staticmethod - def write_coordinator_conversation(project_id: str, conversation: CoordinatorConversationStorage) -> pathlib.Path: - """Writes the Coordinator conversation messages to storage.""" - path = ProjectStorageManager.get_coordinator_conversation_path(project_id) - write_model(path, conversation) - return path - - @staticmethod - def append_coordinator_message( - project_id: str, - message_id: str, - content: str, - sender_name: str, - is_assistant: bool = False, - timestamp: Optional[datetime] = None, - ) -> None: - """ - Appends a message to the Coordinator conversation storage. - - Args: - project_id: The ID of the project - message_id: The ID of the message - content: The message content - sender_name: The name of the sender - is_assistant: Whether the message is from the assistant - timestamp: The timestamp of the message (defaults to now) - """ - # Get existing conversation or create new one - conversation = ProjectStorage.read_coordinator_conversation(project_id) - if not conversation: - conversation = CoordinatorConversationStorage(project_id=project_id) - - # Create new message - new_message = CoordinatorConversationMessage( - message_id=message_id, - content=content, - sender_name=sender_name, - timestamp=timestamp or datetime.utcnow(), - is_assistant=is_assistant, - ) - - # Add to conversation (only keep most recent 50 messages) - conversation.messages.append(new_message) - if len(conversation.messages) > 50: - conversation.messages = conversation.messages[-50:] - - conversation.last_updated = datetime.utcnow() - - # Save the updated conversation - ProjectStorage.write_coordinator_conversation(project_id, conversation) - - @staticmethod - def write_project_whiteboard(project_id: str, whiteboard: ProjectWhiteboard) -> pathlib.Path: - """Writes the project whiteboard.""" - path = ProjectStorageManager.get_project_whiteboard_path(project_id) - write_model(path, whiteboard) - return path - - @staticmethod - def read_information_request(project_id: str, request_id: str) -> Optional[InformationRequest]: - """Reads an information request.""" - path = ProjectStorageManager.get_information_request_path(project_id, request_id) - return read_model(path, InformationRequest) - - @staticmethod - def write_information_request(project_id: str, request: InformationRequest) -> pathlib.Path: - """Writes an information request.""" - # Information requests must have an ID - if not request.request_id: - raise ValueError("Information request must have a request_id") - - path = ProjectStorageManager.get_information_request_path(project_id, request.request_id) - write_model(path, request) - return path - - @staticmethod - def read_project(project_id: str) -> Optional[Project]: - """Reads the complete Project data.""" - path = ProjectStorageManager.get_project_path(project_id) - return read_model(path, Project) - - @staticmethod - def write_project(project_id: str, project: Project) -> pathlib.Path: - """Writes the complete Project data.""" - path = ProjectStorageManager.get_project_path(project_id) - write_model(path, project) - return path - - @staticmethod - def get_all_information_requests(project_id: str) -> List[InformationRequest]: - """Gets all information requests for a project.""" - dir_path = ProjectStorageManager.get_information_requests_dir(project_id) - requests = [] - - if not dir_path.exists(): - return requests - - for file_path in dir_path.glob("*.json"): - request = read_model(file_path, InformationRequest) - if request: - requests.append(request) - - # Sort by updated_at timestamp, newest first - requests.sort(key=lambda r: r.updated_at, reverse=True) - return requests - - @staticmethod - async def refresh_current_ui(context: ConversationContext) -> None: - """ - Refreshes only the current conversation's UI inspector panel. - - This function is now a wrapper that calls the implementation in project_notifications.py. - """ - from .project_notifications import refresh_current_ui - - await refresh_current_ui(context) - - @staticmethod - async def refresh_all_project_uis(context: ConversationContext, project_id: str) -> None: - """ - Refreshes the UI inspector panels of all conversations in a project. - - This function is now a wrapper that calls the implementation in project_notifications.py. - """ - from .project_notifications import refresh_all_project_uis - - await refresh_all_project_uis(context, project_id) - - @staticmethod - async def log_project_event( - context: ConversationContext, - project_id: str, - entry_type: str, - message: str, - related_entity_id: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - ) -> bool: - """ - Logs an event to the project log. - - Args: - context: Current conversation context - project_id: ID of the project - entry_type: Type of log entry - message: Log message - related_entity_id: Optional ID of a related entity (e.g., information request) - metadata: Optional additional metadata - - Returns: - True if the log entry was added successfully, False otherwise - """ - # Get user information - user_id, user_name = await get_current_user(context) - - if not user_id: - return False - - # Default user name if none found - user_name = user_name or "Unknown User" - - # Create a log entry - entry = LogEntry( - entry_type=LogEntryType(entry_type), - message=message, - user_id=user_id, - user_name=user_name, - related_entity_id=related_entity_id, - metadata=metadata or {}, - ) - - # Get existing log or create a new one - log = ProjectStorage.read_project_log(project_id) - if not log: - log = ProjectLog( - entries=[], - ) - - # Add the entry and update metadata - log.entries.append(entry) - - # Save the updated log - ProjectStorage.write_project_log(project_id, log) - return True diff --git a/assistants/project-assistant/assistant/project_storage_models.py b/assistants/project-assistant/assistant/project_storage_models.py deleted file mode 100644 index 81aec687e..000000000 --- a/assistants/project-assistant/assistant/project_storage_models.py +++ /dev/null @@ -1,43 +0,0 @@ -""" -Models for project storage entities. - -This module contains data models specific to storage operations, -separate from the core project data models. -""" - -from datetime import datetime -from enum import Enum -from typing import List - -from pydantic import BaseModel, Field - - -class ConversationRole(str, Enum): - """ - Enumeration of conversation roles in a project. - - This enum represents the role that a conversation plays in a project, - either as a Coordinator (managing the project) or as a Team member - (participating in the project). - """ - - COORDINATOR = "coordinator" - TEAM = "team" - - -class CoordinatorConversationMessage(BaseModel): - """Model for storing a message from Coordinator conversation for Team access.""" - - message_id: str - content: str - sender_name: str - timestamp: datetime = Field(default_factory=datetime.utcnow) - is_assistant: bool = False - - -class CoordinatorConversationStorage(BaseModel): - """Model for storing a collection of Coordinator conversation messages.""" - - project_id: str - last_updated: datetime = Field(default_factory=datetime.utcnow) - messages: List[CoordinatorConversationMessage] = Field(default_factory=list) \ No newline at end of file diff --git a/assistants/project-assistant/assistant/prompt_utils.py b/assistants/project-assistant/assistant/prompt_utils.py new file mode 100644 index 000000000..92b52be12 --- /dev/null +++ b/assistants/project-assistant/assistant/prompt_utils.py @@ -0,0 +1,487 @@ +import json +from dataclasses import dataclass, field +from enum import Enum +from typing import Protocol + +import openai_client +from assistant_extensions.attachments import ( + AttachmentsConfigModel, + AttachmentsExtension, +) +from openai.types.chat import ( + ChatCompletionMessageParam, +) +from pydantic import BaseModel, Field +from semantic_workbench_assistant.assistant_app import ( + ConversationContext, +) + +from assistant.data import ( + ConversationRole, + CoordinatorConversationMessage, + RequestStatus, +) +from assistant.domain.learning_objectives_manager import LearningObjectivesManager +from assistant.domain.share_manager import ShareManager +from assistant.domain.tasks_manager import TasksManager +from assistant.ui_tabs.common import get_priority_emoji, get_status_emoji + + +def create_system_message(content: str, delimiter: str | None = None) -> ChatCompletionMessageParam: + if delimiter: + delimiter = delimiter.strip().upper().replace(" ", "_") + content = f"<{delimiter}>\n{content}\n</{delimiter}>" + + message: ChatCompletionMessageParam = { + "role": "system", + "content": content, + } + return message + + +class Instructions: + """ + A class to represent a section of a prompt. + """ + + def __init__( + self, + content: str, + title: str | None = None, + ) -> None: + self.title = title + self.content = content + self.level = 0 + self.subsections: list[Instructions] = [] + + def add_subsection(self, subsection: "Instructions") -> None: + """ + Add a subsection to the prompt section. + """ + subsection.level = self.level + 1 + self.subsections.append(subsection) + + def __str__(self) -> str: + s = "" + if self.title: + hashes = "#" * (self.level + 1) + s += f"{hashes} {self.title}\n\n" + s += self.content + if self.subsections: + s += "\n\n" + "\n\n".join(str(subsection) for subsection in self.subsections) + + return s + + +class Context(Protocol): + def message(self) -> ChatCompletionMessageParam: + raise NotImplementedError + + def content(self) -> str: + raise NotImplementedError + + def name(self) -> str: + raise NotImplementedError + + +class ChatCompletionMessageContext(Context): + def __init__(self, message: ChatCompletionMessageParam, name: str | None) -> None: + self._message = message + self._name = name or "Attachment" + + def message(self) -> ChatCompletionMessageParam: + return self._message + + def content(self) -> str: + return f"<{self._name}>\n{self._message.get('content')}\n</{self._name}>" + + def name(self) -> str: + return self._name + + +class DataContext(Context): + def __init__(self, name: str, data: str, description: str | None = None) -> None: + self._name = name + self.description = description + self.data = data + + def message(self) -> ChatCompletionMessageParam: + return create_system_message(self.content(), self._name) + + def content(self) -> str: + s = self.data + if self.description: + s = f"{self.description}\n\n'''\n{self.data}\n'''" + return s + + def name(self) -> str: + return self._name + + +class ContextStrategy(Enum): + SINGLE = "single" # Put all context chunks in a single message. + MULTI = "multi" # Put each context chunk in its own message. + + +@dataclass +class Prompt: + instructions: Instructions + output_format: str | None = None + reasoning_steps: str | None = None + examples: str | None = None + contexts: list[Context] = field(default_factory=list) + context_strategy: ContextStrategy = ContextStrategy.SINGLE + final_instructions: str | None = None + + def messages(self) -> list[ChatCompletionMessageParam]: + parts = [ + str(self.instructions), + ] + if self.reasoning_steps: + parts.append("# Reasoning Steps") + parts.append(self.reasoning_steps) + if self.output_format: + parts.append("# Output Format") + parts.append(self.output_format) + if self.examples: + parts.append("# Examples") + parts.append(self.examples) + if self.contexts and self.context_strategy == ContextStrategy.SINGLE: + parts.append("# Context") + for context in self.contexts: + parts.append(f"## {context.name()}") + parts.append(context.content()) + s = "\n\n".join(parts) + if self.final_instructions: + s += "\n\n" + self.final_instructions + + messages = [ + create_system_message(s), + ] + + if self.contexts and self.context_strategy == ContextStrategy.MULTI: + for context in self.contexts: + messages.append(context.message()) + + return messages + + +class TokenBudget: + def __init__(self, budget: int) -> None: + self.budget = budget + self.used = 0 + + def add(self, tokens: int) -> None: + self.used += tokens + + def remaining(self) -> int: + return self.budget - self.used + + def is_under_budget(self) -> bool: + return self.remaining() > 0 + + def is_over_budget(self) -> bool: + return self.remaining() < 0 + + def fits(self, tokens: int) -> bool: + return self.remaining() >= tokens + + +class ContextSection(Enum): + """ + Enum to represent different sections of the conversation context. + """ + + KNOWLEDGE_INFO = "knowledge_info" + KNOWLEDGE_BRIEF = "knowledge_brief" + TARGET_AUDIENCE = "target_audience" + LEARNING_OBJECTIVES = "learning_objectives" + KNOWLEDGE_DIGEST = "knowledge_digest" + INFORMATION_REQUESTS = "information_requests" + SUGGESTED_NEXT_ACTIONS = "suggested_next_actions" + COORDINATOR_CONVERSATION = "coordinator_conversation" + ATTACHMENTS = "attachments" + TASKS = "tasks" + + +async def add_context_to_prompt( + prompt: Prompt, + context: ConversationContext, + role: ConversationRole, + model: str, + token_limit: int, + attachments_extension: AttachmentsExtension | None = None, + attachments_config: AttachmentsConfigModel | None = None, + attachments_in_system_message: bool = False, + include: list[ContextSection] | None = None, +) -> None: + if include is None: + return + + share = await ShareManager.get_share(context) + + if ContextSection.TASKS in include: + tasks = await TasksManager.get_tasks(context) + if tasks: + tasks_data = json.dumps([task.model_dump() for task in tasks]) + prompt.contexts.append( + DataContext( + "Task List", + tasks_data, + ) + ) + + if ContextSection.KNOWLEDGE_INFO in include: + share_info_text = share.model_dump_json( + indent=2, + exclude={ + "brief", + "learning_objectives", + "audience_takeaways", + "preferred_communication_style", + "digest", + "next_learning_actions", + "requests", + "tasks", + "log", + }, + ) + prompt.contexts.append(DataContext("Knowledge Share Info", share_info_text)) + + if ContextSection.KNOWLEDGE_BRIEF in include and share and share.brief: + brief_text = "" + brief_text = f"**Title:** {share.brief.title}\n**Description:** {share.brief.content}" + prompt.contexts.append( + DataContext( + "Knowledge Brief", + brief_text, + ) + ) + + if ContextSection.TARGET_AUDIENCE in include and role == ConversationRole.COORDINATOR and share: + if share.audience: + audience_context = share.audience + + if share.audience_takeaways: + audience_context += "\n\n**Intended takeaways for this audience:**\n" + audience_context += "\n".join(f"- {takeaway}" for takeaway in share.audience_takeaways) + else: + audience_context += "\n\n**Note:** No specific takeaways defined for this audience. Please define them to help guide the knowledge transfer process." # noqa: E501 + + if not share.is_intended_to_accomplish_outcomes: + audience_context += "\n\n**Note:** This knowledge package is intended for general exploration, not specific learning outcomes." # noqa: E501 + else: + audience_context = "The intended audience for this knowledge transfer has not been defined yet. Please define it to help guide the knowledge transfer process." # noqa: E501 + + prompt.contexts.append( + DataContext( + "Target Audience", + audience_context, + ) + ) + + # Learning objectives + if ContextSection.LEARNING_OBJECTIVES in include and share and share.learning_objectives: + learning_objectives_text = "" + conversation_id = str(context.id) + + # Show progress based on role + if role == ConversationRole.COORDINATOR: + # Coordinator sees overall progress across all team members + achieved_overall, total_overall = LearningObjectivesManager.get_overall_completion(share) + learning_objectives_text += ( + f"Overall Progress: {achieved_overall}/{total_overall} outcomes achieved by team members\n\n" + ) + else: + # Team member sees their personal progress + if conversation_id in share.team_conversations: + achieved_personal, total_personal = LearningObjectivesManager.get_completion_for_conversation( + share, conversation_id + ) + progress_pct = int(achieved_personal / total_personal * 100) if total_personal > 0 else 0 + learning_objectives_text += ( + f"My Progress: {achieved_personal}/{total_personal} outcomes achieved ({progress_pct}%)\n\n" + ) + + learning_objectives = {} + for objective in share.learning_objectives: + learning_objectives[objective.id] = objective.model_dump() + learning_objectives_text = json.dumps( + learning_objectives, + indent=2, + ) + + prompt.contexts.append( + DataContext( + "Learning Objectives", + learning_objectives_text, + ) + ) + + if ContextSection.KNOWLEDGE_DIGEST in include and share and share.digest and share.digest.content: + prompt.contexts.append( + DataContext( + "Knowledge digest", + share.digest.content, + "The assistant-maintained knowledge digest.", + ) + ) + + if ContextSection.INFORMATION_REQUESTS in include and share: + all_requests = share.requests + if role == ConversationRole.COORDINATOR: + active_requests = [r for r in all_requests if r.status != RequestStatus.RESOLVED] + if active_requests: + coordinator_requests = "" + for req in active_requests[:10]: # Limit to 10 for brevity + priority_emoji = get_priority_emoji(req.priority) + status_emoji = get_status_emoji(req.status) + coordinator_requests = f"{priority_emoji} **{req.title}** {status_emoji}\n" + coordinator_requests += f" **Request ID:** `{req.request_id}`\n" + coordinator_requests += f" **Description:** {req.description}\n\n" + + if len(active_requests) > 10: + coordinator_requests += f"*...and {len(active_requests) - 10} more requests.*\n" + else: + coordinator_requests = "No active information requests." + prompt.contexts.append( + DataContext( + "Information Requests", + coordinator_requests, + ) + ) + else: # team role + information_requests_info = "" + my_requests = [] + + # Filter for requests from this conversation that aren't resolved. + my_requests = [ + r for r in all_requests if r.conversation_id == str(context.id) and r.status != RequestStatus.RESOLVED + ] + + if my_requests: + information_requests_info = "" + for req in my_requests: + information_requests_info += ( + f"- **{req.title}** (ID: `{req.request_id}`, Priority: {req.priority})\n" + ) + else: + information_requests_info = "No active information requests." + + prompt.contexts.append( + DataContext( + "Information Requests", + information_requests_info, + ) + ) + + # if ContextSection.SUGGESTED_NEXT_ACTIONS in include and share and role == ConversationRole.COORDINATOR: + # next_action_suggestion = await get_coordinator_next_action_suggestion(context) + # if next_action_suggestion: + # prompt.contexts.append( + # DataContext( + # "Suggested Next Actions", + # next_action_suggestion, + # "Actions the coordinator should consider taking based on the current knowledge transfer state.", + # ) + # ) + + # Figure out the token budget so far. + token_budget = TokenBudget(token_limit) + token_budget.add( + openai_client.num_tokens_from_messages( + model=model, + messages=prompt.messages(), + ) + ) + + # Coordinator conversation + if ContextSection.COORDINATOR_CONVERSATION in include: + coordinator_conversation = await ShareManager.get_coordinator_conversation(context) + if coordinator_conversation: + # Limit messages to the configured max token count. + total_coordinator_conversation_tokens = 0 + selected_coordinator_conversation_messages: list[CoordinatorConversationMessage] = [] + for msg in reversed(coordinator_conversation.messages): + tokens = openai_client.num_tokens_from_string(msg.model_dump_json(), model=model) + if total_coordinator_conversation_tokens + tokens > token_limit: + break + selected_coordinator_conversation_messages.append(msg) + total_coordinator_conversation_tokens += tokens + + class CoordinatorMessageList(BaseModel): + messages: list[CoordinatorConversationMessage] = Field(default_factory=list) + + selected_coordinator_conversation_messages.reverse() + coordinator_message_list = CoordinatorMessageList(messages=selected_coordinator_conversation_messages) + coordinator_message_list_data = coordinator_message_list.model_dump_json() + + if attachments_in_system_message: + prompt.contexts.append( + DataContext( + "Message History" if role == ConversationRole.COORDINATOR else "Coordinator Conversation", + coordinator_message_list_data, + ) + ) + else: + if role == ConversationRole.COORDINATOR: + coordinator_message_list_data = ( + f"<MESSAGE_HISTORY>{coordinator_message_list_data}</MESSAGE_HISTORY>" + ) + else: + coordinator_message_list_data = ( + f"<COORDINATOR_CONVERSATION>{coordinator_message_list_data}</COORDINATOR_CONVERSATION>" + ) + prompt.contexts.append(DataContext("Attachment", coordinator_message_list_data)) + + # TODO: To get exact token count, we should add delimiters. + token_budget.add( + openai_client.num_tokens_from_string( + model=model, + string=coordinator_message_list_data, + ) + ) + + # Attachments + if ContextSection.ATTACHMENTS in include and share and attachments_config and attachments_extension: + # Generate the attachment messages. + # TODO: This will exceed the token limit if there are too many attachments. + + attachment_messages: list[ChatCompletionMessageParam] = openai_client.convert_from_completion_messages( + await attachments_extension.get_completion_messages_for_attachments( + context, + config=attachments_config, + ) + ) + + if attachments_in_system_message: + attachments_data = "\n\n".join(f"{msg['content']}" for msg in attachment_messages if "content" in msg) + prompt.contexts.append( + DataContext( + "Attachments", + attachments_data, + "The attachments provided by the user.", + ) + ) + # TODO: To get exact token count, we should add delimiters. + token_budget.add( + openai_client.num_tokens_from_string( + model=model, + string=attachments_data, + ) + ) + + else: + for a in attachment_messages: + prompt.contexts.append( + ChatCompletionMessageContext( + name="Attachment", + message=a, + ) + ) + token_budget.add( + openai_client.num_tokens_from_messages( + model=model, + messages=attachment_messages, + ) + ) diff --git a/assistants/project-assistant/assistant/respond.py b/assistants/project-assistant/assistant/respond.py deleted file mode 100644 index 920c116c1..000000000 --- a/assistants/project-assistant/assistant/respond.py +++ /dev/null @@ -1,614 +0,0 @@ -import re -import time -from textwrap import dedent -from typing import Any, Dict, List - -import openai_client -from assistant_extensions.attachments import AttachmentsExtension -from openai import BaseModel -from openai.types.chat import ( - ChatCompletionAssistantMessageParam, - ChatCompletionMessageParam, - ChatCompletionSystemMessageParam, - ChatCompletionUserMessageParam, -) -from openai_client import num_tokens_from_messages -from openai_client.completion import message_content_from_completion -from openai_client.tools import complete_with_tool_calls -from pydantic import Field -from semantic_workbench_api_model.workbench_model import ( - ConversationMessage, - ConversationParticipantList, - MessageType, - NewConversationMessage, -) -from semantic_workbench_assistant.assistant_app import ( - ConversationContext, -) - -from .config import assistant_config -from .logging import logger -from .project_analysis import detect_information_request_needs -from .project_common import detect_assistant_role -from .project_data import RequestStatus -from .project_manager import ProjectManager -from .project_storage import ProjectStorage -from .project_storage_models import ConversationRole, CoordinatorConversationMessage -from .string_utils import Context, ContextStrategy, Instructions, Prompt, TokenBudget, render -from .tools import ProjectTools -from .utils import load_text_include - -SILENCE_TOKEN = "{{SILENCE}}" - - -def format_message(participants: ConversationParticipantList, message: ConversationMessage) -> str: - """Consistent formatter that includes the participant name for multi-participant and name references""" - conversation_participant = next( - (participant for participant in participants.participants if participant.id == message.sender.participant_id), - None, - ) - participant_name = conversation_participant.name if conversation_participant else "unknown" - message_datetime = message.timestamp.strftime("%Y-%m-%d %H:%M:%S") - return f"[{participant_name} - {message_datetime}]: {message.content}" - - -async def respond_to_conversation( - context: ConversationContext, - new_message: ConversationMessage, - attachments_extension: AttachmentsExtension, - metadata: Dict[str, Any], -) -> None: - """ - Respond to a conversation message. - """ - if "debug" not in metadata: - metadata["debug"] = {} - - # Config - config = await assistant_config.get(context.assistant) - model = config.request_config.openai_model - - # Requirements - role = await detect_assistant_role(context) - metadata["debug"]["role"] = role - project_id = await ProjectManager.get_project_id(context) - if not project_id: - raise ValueError("Project ID not found in context") - - token_budget = TokenBudget(config.request_config.max_tokens) - - ## - ## INSTRUCTIONS - ## - - # Add role-specific instructions. - if role == ConversationRole.COORDINATOR: - assistant_role = config.prompt_config.coordinator_role - role_specific_instructions = config.prompt_config.coordinator_instructions - else: - assistant_role = config.prompt_config.team_role - role_specific_instructions = config.prompt_config.team_instructions - instructions = Instructions(role_specific_instructions) - - # Add whiteboard instructions. - instructions.add_subsection( - Instructions( - render(load_text_include("whiteboard_instructions.txt"), {"project_or_context": config.project_or_context}), - "Assistant Whiteboard", - ) - ) - - # If this is a multi-participant conversation, add a note about the participants. - participants = await context.get_participants(include_inactive=True) - if len(participants.participants) > 2: - participant_text = ( - "\n\n" - f"There are {len(participants.participants)} participants in the conversation," - " including you as the assistant and the following users:" - + ",".join([ - f' "{participant.name}"' - for participant in participants.participants - if participant.id != context.assistant.id - ]) - + "\n\nYou do not need to respond to every message. Do not respond if the last thing said was a closing" - " statement such as 'bye' or 'goodbye', or just a general acknowledgement like 'ok' or 'thanks'. Do not" - f' respond as another user in the conversation, only as "{context.assistant.name}".' - " Sometimes the other users need to talk amongst themselves and that is ok. If the conversation seems to" - f' be directed at you or the general audience, go ahead and respond.\n\nSay "{SILENCE_TOKEN}" to skip' - " your turn." - ) - instructions.add_subsection(Instructions(participant_text, "Multi-participant conversation instructions")) - - prompt = Prompt( - role=assistant_role, - instructions=instructions, - context_strategy=ContextStrategy.MULTI, - output_format="Respond as JSON with your response in the `response` field and all citations in the `citations` field. In the `next_step_suggestion` field, suggest more areas to explore using content from the assistant whiteboard to ensure your conversation covers all of the relevant information.", - ) - - ### - ### Context - ### - - # Project info - project_info = ProjectStorage.read_project_info(project_id) - if project_info: - project_info_text = project_info.model_dump_json(indent=2) - prompt.contexts.append(Context(f"{config.Project_or_Context} Info", project_info_text)) - - # Brief - briefing = ProjectStorage.read_project_brief(project_id) - project_brief_text = "" - if briefing: - project_brief_text = f"**Title:** {briefing.title}\n**Description:** {briefing.description}" - prompt.contexts.append( - Context( - f"{config.Project_or_Context} Brief", - project_brief_text, - ) - ) - - # Project goals - project = ProjectStorage.read_project(project_id) - if project and project.goals: - goals_text = "" - for i, goal in enumerate(project.goals): - # Count completed criteria - completed = sum(1 for c in goal.success_criteria if c.completed) - total = len(goal.success_criteria) - - project_brief_text += f"{i + 1}. **{goal.name}** - {goal.description}\n" - if goal.success_criteria: - goals_text += f" Progress: {completed}/{total} criteria complete\n" - for criterion in goal.success_criteria: - check = "✅" if criterion.completed else "⬜" - goals_text += f" {check} {criterion.description}\n" - prompt.contexts.append( - Context( - "Project Goals", - goals_text, - ) - ) - - # Whiteboard - whiteboard = ProjectStorage.read_project_whiteboard(project_id) - if whiteboard and whiteboard.content: - prompt.contexts.append(Context("Assistant Whiteboard", whiteboard.content, "The assistant's whiteboard")) - - # Information requests - all_requests = ProjectStorage.get_all_information_requests(project_id) - if role == ConversationRole.COORDINATOR: - active_requests = [r for r in all_requests if r.status != RequestStatus.RESOLVED] - if active_requests: - coordinator_requests = "> 📋 **Use the request ID (not the title) with resolve_information_request()**\n\n" - for req in active_requests[:10]: # Limit to 10 for brevity - priority_marker = { - "low": "🔹", - "medium": "🔶", - "high": "🔴", - "critical": "⚠️", - }.get(req.priority.value, "🔹") - - coordinator_requests += f"{priority_marker} **{req.title}** ({req.status.value})\n" - coordinator_requests += f" **Request ID:** `{req.request_id}`\n" - coordinator_requests += f" **Description:** {req.description}\n\n" - - if len(active_requests) > 10: - coordinator_requests += f'*...and {len(active_requests) - 10} more requests. Use get_project_info(info_type="requests") to see all.*\n' - else: - coordinator_requests = "No active information requests." - prompt.contexts.append( - Context( - "Information Requests", - coordinator_requests, - ) - ) - else: # team role - information_requests_info = "" - my_requests = [] - - # Filter for requests from this conversation that aren't resolved. - my_requests = [ - r for r in all_requests if r.conversation_id == str(context.id) and r.status != RequestStatus.RESOLVED - ] - - if my_requests: - information_requests_info = "" - for req in my_requests: - information_requests_info += f"- **{req.title}** (ID: `{req.request_id}`, Priority: {req.priority})\n" - information_requests_info += ( - '\nYou can delete any of these requests using `delete_information_request(request_id="the_id")`\n' - ) - else: - information_requests_info = "No active information requests." - - prompt.contexts.append( - Context( - "Information Requests", - information_requests_info, - ) - ) - - # Calculate token count for all system messages so far. - completion_messages = prompt.messages() - token_budget.add( - num_tokens_from_messages( - model=model, - messages=completion_messages, - ) - ) - - ### - ### Coordinator conversation as an attachment. - ### - - # Get the coordinator conversation and add it as an attachment. - coordinator_conversation = ProjectStorage.read_coordinator_conversation(project_id) - if coordinator_conversation: - # Limit messages to the configured max token count. - total_coordinator_conversation_tokens = 0 - selected_coordinator_conversation_messages: List[CoordinatorConversationMessage] = [] - for msg in reversed(coordinator_conversation.messages): - tokens = openai_client.num_tokens_from_string(msg.model_dump_json(), model=model) - if ( - total_coordinator_conversation_tokens + tokens - > config.request_config.coordinator_conversation_token_limit - ): - break - selected_coordinator_conversation_messages.append(msg) - total_coordinator_conversation_tokens += tokens - - # Create a new coordinator conversation system message with the selected messages. - class CoordinatorMessageList(BaseModel): - messages: List[CoordinatorConversationMessage] = Field(default_factory=list) - - selected_coordinator_conversation_messages.reverse() - coordinator_message_list = CoordinatorMessageList(messages=selected_coordinator_conversation_messages) - coordinator_conversation_message = ChatCompletionSystemMessageParam( - role="system", - content=( - f"<ATTACHMENT><FILENAME>CoordinatorConversation.json</FILENAME><CONTENT>{coordinator_message_list.model_dump_json()}</CONTENT>" - ), - ) - completion_messages.append(coordinator_conversation_message) - - token_budget.add( - num_tokens_from_messages( - model=model, - messages=[coordinator_conversation_message], - ) - ) - - ### - ### ATTACHMENTS - ### - - # TODO: A better pattern here might be to keep the attachements as user - # in the proper flow of the conversation rather than as . - - # Generate the attachment messages. - attachment_messages: List[ChatCompletionMessageParam] = openai_client.convert_from_completion_messages( - await attachments_extension.get_completion_messages_for_attachments( - context, - config=config.attachments_config, - ) - ) - - # TODO: This will exceed the token limit if there are too many attachments. - # We do give them a warning below, though, and tell them to remove - # attachments if this happens. - - token_budget.add( - num_tokens_from_messages( - model=model, - messages=attachment_messages, - ) - ) - completion_messages.extend(attachment_messages) - - ### - ### USER MESSAGE - ### - - if new_message.sender.participant_id == context.assistant.id: - user_message: ChatCompletionMessageParam = ChatCompletionAssistantMessageParam( - role="assistant", - content=format_message(participants, new_message), - ) - else: - user_message: ChatCompletionMessageParam = ChatCompletionUserMessageParam( - role="user", - content=format_message(participants, new_message), - ) - - token_budget.add( - num_tokens_from_messages( - model=model, - messages=[user_message], - ) - ) - - ### - ### HISTORY MESSAGES - ### - - history_messages: list[ChatCompletionMessageParam] = [] - before_message_id = new_message.id - history_token_budget = TokenBudget(token_budget.remaining()) - - # Fetch messages from the workbench in batches that will fit our token budget. - under_budget = True - while under_budget: - # Get a batch of messages - messages_response = await context.get_messages( - before=before_message_id, - limit=100, - message_types=[MessageType.chat], - ) - messages_list = messages_response.messages - if not messages_list or len(messages_list) == 0: - break - before_message_id = messages_list[0].id - - for msg in reversed(messages_list): - if msg.sender.participant_id == context.assistant.id: - current_message = ChatCompletionAssistantMessageParam( - role="assistant", - content=format_message(participants, msg), - ) - else: - current_message = ChatCompletionUserMessageParam( - role="user", - content=format_message(participants, msg), - ) - - current_message_tokens = num_tokens_from_messages( - model=model, - messages=[current_message], - ) - - if history_token_budget.fits(current_message_tokens): - history_messages = [current_message] + history_messages - history_token_budget.add(current_message_tokens) - else: - under_budget = False - break - - if not under_budget: - break - - # Add all chat messages. - completion_messages.extend(history_messages) - completion_messages.append(user_message) - - # Add a system message to indicate attachments are a part of the new message. - if new_message.filenames and len(new_message.filenames) > 0: - attachment_message = ChatCompletionSystemMessageParam( - role="system", - content=f"Attachment(s): {', '.join(new_message.filenames)}", - ) - completion_messages.append(attachment_message) - token_budget.add( - num_tokens_from_messages( - model=model, - messages=[attachment_message], - ) - ) - - ## - ## Final token count check - ## - token_counts = {"total": token_budget.used, "max": token_budget.budget} - metadata["debug"]["token_usage"] = token_counts # For debug. - metadata["token_counts"] = token_counts # For footer. - if token_budget.remaining() < 0: - raise ValueError( - f"You've exceeded the token limit of {token_budget.budget} in this conversation " - f"({token_budget.used}). Try removing some attachments." - ) - - # For team role, analyze message for possible information request needs. - # Send a notification if we think it might be one. - if role is ConversationRole.TEAM: - detection_result = await detect_information_request_needs(context, new_message.content) - - if detection_result.get("is_information_request", False) and detection_result.get("confidence", 0) > 0.8: - suggested_title = detection_result.get("potential_title", "") - suggested_priority = detection_result.get("suggested_priority", "medium") - potential_description = detection_result.get("potential_description", "") - reason = detection_result.get("reason", "") - - suggestion = ( - f"**Potential _Information Request_ Detected**\n\n" - f"It appears that you might need information from the {config.project_or_context} coordinator. {reason}\n\n" - f"Would you like me to create an information request?\n" - f"**Title:** {suggested_title}\n" - f"**Description:** {potential_description}\n" - f"**Priority:** {suggested_priority}\n\n" - ) - - await context.send_messages( - NewConversationMessage( - content=suggestion, - message_type=MessageType.notice, - metadata={"debug": detection_result}, - ) - ) - metadata["debug"]["detection_result"] = detection_result - - ## - ## MAKE THE LLM CALL - ## - - class Output(BaseModel): - """ - Attributes: - citations: A list of citations from which the response is generated. There should always be at least one citation, but it can be empty if the assistant has no relevant information to cite. - excerpt: A verbatim excerpt from one of the cited works that illustrates why this response was given. It should have enough context to get a good idea of what's in that part of the cited work. If there is no relevant excerpt, this will be None. - next_step_suggestion: Suggest more areas to explore using content from the assistant whiteboard to ensure your conversation covers all of the relevant information. - """ - - citations: list[str] = Field( - description="A list of citations from which the response is generated. There should always be at least one citation, but it can be empty if the assistant has no relevant information to cite.", - ) - excerpt: str | None = Field( - description="A verbatim excerpt from one of the cited works that illustrates why this response was given. It should have enough context to get a good idea of what's in that part of the cited work. If there is no relevant excerpt, this will be None.", - ) - response: str = Field( - description="The response from the assistant.", - ) - next_step_suggestion: str = Field( - description="Suggest more areas to explore using content from the assistant whiteboard to ensure your conversation covers all of the relevant information. For example: 'Would you like to explore ... next?'.", - ) - - model_config = { - "extra": "forbid" # This sets additionalProperties=false in the schema - } - - async with openai_client.create_client(config.service_config) as client: - try: - completion_args = { - "messages": completion_messages, - "model": model, - "max_tokens": config.request_config.response_tokens, - "response_format": Output, - } - - project_tools = ProjectTools(context, role) - response_start_time = time.time() - completion_response, additional_messages = await complete_with_tool_calls( - async_client=client, - completion_args=completion_args, - tool_functions=project_tools.tool_functions, - metadata=metadata["debug"], - ) - response_end_time = time.time() - footer_items = [] - - # Add the token usage message to the footer items - if completion_response: - response_tokens = completion_response.usage.completion_tokens if completion_response.usage else 0 - request_tokens = token_budget.used - footer_items.append( - get_token_usage_message( - max_tokens=config.request_config.max_tokens, - total_tokens=request_tokens + response_tokens, - request_tokens=request_tokens, - completion_tokens=response_tokens, - ) - ) - - await context.update_conversation( - metadata={ - "token_counts": { - "total": request_tokens + response_tokens, - "max": config.request_config.max_tokens, - } - } - ) - - footer_items.append(get_response_duration_message(response_end_time - response_start_time)) - metadata["footer_items"] = footer_items - - content = message_content_from_completion(completion_response) - if not content: - content = "I've processed your request, but couldn't generate a proper response." - - except Exception as e: - logger.exception(f"exception occurred calling openai chat completion: {e}") - content = "An error occurred while calling the OpenAI API. Is it configured correctly?" - metadata["debug"]["error"] = str(e) - - if content: - # strip out the username from the response - if isinstance(content, str) and content.startswith("["): - content = re.sub(r"\[.*\]:\s", "", content) - - # check for the silence token, in case the model chooses not to respond - # model sometimes puts extra spaces in the response, so remove them - # when checking for the silence token - if isinstance(content, str) and content.replace(" ", "") == SILENCE_TOKEN: - # normal behavior is to not respond if the model chooses to remain silent - # but we can override this behavior for debugging purposes via the assistant config - if config.enable_debug_output: - metadata["debug"]["silence_token"] = True - metadata["debug"]["silence_token_response"] = (content,) - await context.send_messages( - NewConversationMessage( - message_type=MessageType.notice, - content="[assistant chose to remain silent]", - metadata=metadata, - ) - ) - return - - # Prepare response and citations. - response_parts: list[str] = [] - try: - output_model = Output.model_validate_json(content) - if output_model.response: - response_parts.append(output_model.response) - - if role == ConversationRole.TEAM and output_model.excerpt: - output_model.excerpt = output_model.excerpt.strip().strip('"') - response_parts.append(f'> _"{output_model.excerpt}"_ (excerpt)') - - if role == ConversationRole.TEAM and output_model.next_step_suggestion: - response_parts.append(output_model.next_step_suggestion) - - if role == ConversationRole.TEAM and output_model.citations: - citations = ", ".join(output_model.citations) - response_parts.append(f"Sources: _{citations}_") - - except Exception as e: - logger.exception(f"exception occurred parsing json response: {e}") - metadata["debug"]["error"] = str(e) - response_parts.append(content) - - await context.send_messages( - NewConversationMessage( - content="\n\n".join(response_parts), - message_type=MessageType.chat, - metadata=metadata, - ) - ) - - -def get_formatted_token_count(tokens: int) -> str: - # if less than 1k, return the number of tokens - # if greater than or equal to 1k, return the number of tokens in k - # use 1 decimal place for k - # drop the decimal place if the number of tokens in k is a whole number - if tokens < 1000: - return str(tokens) - else: - tokens_in_k = tokens / 1000 - if tokens_in_k.is_integer(): - return f"{int(tokens_in_k)}k" - else: - return f"{tokens_in_k:.1f}k" - - -def get_token_usage_message( - max_tokens: int, - total_tokens: int, - request_tokens: int, - completion_tokens: int, -) -> str: - """ - Generate a display friendly message for the token usage, to be added to the footer items. - """ - - return dedent(f""" - Tokens used: {get_formatted_token_count(total_tokens)} - ({get_formatted_token_count(request_tokens)} in / {get_formatted_token_count(completion_tokens)} out) - of {get_formatted_token_count(max_tokens)} ({int(total_tokens / max_tokens * 100)}%) - """).strip() - - -def get_response_duration_message(response_duration: float) -> str: - """ - Generate a display friendly message for the response duration, to be added to the footer items. - """ - - return f"Response time: {response_duration:.2f} seconds" diff --git a/assistants/project-assistant/assistant/state_inspector.py b/assistants/project-assistant/assistant/state_inspector.py deleted file mode 100644 index e798a2d26..000000000 --- a/assistants/project-assistant/assistant/state_inspector.py +++ /dev/null @@ -1,311 +0,0 @@ -""" -Project assistant inspector state provider. - -This module provides the state inspector provider for the project assistant -to display project information in the workbench UI's inspector panel. -""" - -import logging -from typing import Any, List - -from semantic_workbench_assistant.assistant_app import ( - AssistantConversationInspectorStateDataModel, - ConversationContext, -) - -from .conversation_project_link import ConversationProjectManager -from .project_common import detect_assistant_role -from .project_data import RequestStatus -from .project_manager import ProjectManager -from .project_storage import ProjectStorage -from .project_storage_models import ConversationRole - -logger = logging.getLogger(__name__) - - -class ProjectInspectorStateProvider: - """ - Inspector state provider for project information. - - This provider displays project-specific information in the inspector panel - including project state, brief, goals, and information requests based on the - user's role (Coordinator or Team). - - The content displayed is adapted based on the template configuration: - - Default: Shows brief, goals, criteria, and request status - - Context Transfer: Focuses on knowledge context without goals or progress tracking - """ - - # Default display name and description - display_name = "Status" - description = "" - - def __init__(self, config_provider) -> None: - self.config_provider = config_provider - - async def is_enabled(self, context: ConversationContext) -> bool: - return True - - async def get(self, context: ConversationContext) -> AssistantConversationInspectorStateDataModel: - """ - Get project information for display in the inspector panel. - """ - - # State variables that will determine the content to display. - conversation_role = await detect_assistant_role(context) - - # Determine the conversation's role and project - project_id = await ConversationProjectManager.get_associated_project_id(context) - if not project_id: - return AssistantConversationInspectorStateDataModel( - data={"content": "No active project. Start a conversation to create one."} - ) - - # Get project information - brief = await ProjectManager.get_project_brief(context) - project_info = await ProjectManager.get_project_info(context) - - if conversation_role == ConversationRole.COORDINATOR: - markdown = await self._format_coordinator_markdown( - project_id, conversation_role, brief, project_info, context - ) - else: - markdown = await self._format_team_markdown(project_id, conversation_role, brief, project_info, context) - - return AssistantConversationInspectorStateDataModel(data={"content": markdown}) - - async def _format_coordinator_markdown( - self, - project_id: str, - role: ConversationRole, - brief: Any, - project_info: Any, - context: ConversationContext, - ) -> str: - """Format project information as markdown for Coordinator role""" - - lines: List[str] = [] - - # Get the project - project = ProjectStorage.read_project(project_id) - - lines.append("**Role:** Coordinator") - - stage_label = "Planning Stage" - if project_info and project_info.state: - if project_info.state.value == "planning": - stage_label = "Planning Stage" - elif project_info.state.value == "ready_for_working": - stage_label = "Ready for Working" - elif project_info.state.value == "in_progress": - stage_label = "Working Stage" - elif project_info.state.value == "completed": - stage_label = "Completed Stage" - elif project_info.state.value == "aborted": - stage_label = "Aborted Stage" - lines.append(f"**Status:** {stage_label}") - - if project_info and project_info.status_message: - lines.append(f"**Status Message:** {project_info.status_message}") - - lines.append("") - - lines.append("Project Brief") - - title = brief.title if brief else "Untitled" - lines.append(f"### {title}") - lines.append("") - - if brief and brief.description: - lines.append(brief.description) - lines.append("") - - # In context transfer mode, show additional context in a dedicated section - if brief.additional_context: - lines.append("## Additional Context") - lines.append(brief.additional_context) - lines.append("") - - # Add goals section if available and progress tracking is enabled - if project and project.goals: - lines.append("## Goals") - for goal in project.goals: - criteria_complete = sum(1 for c in goal.success_criteria if c.completed) - criteria_total = len(goal.success_criteria) - lines.append(f"### {goal.name}") - lines.append(goal.description) - lines.append(f"**Progress:** {criteria_complete}/{criteria_total} criteria complete") - - if goal.success_criteria: - lines.append("") - lines.append("#### Success Criteria:") - for criterion in goal.success_criteria: - status_emoji = "✅" if criterion.completed else "⬜" - lines.append(f"- {status_emoji} {criterion.description}") - lines.append("") - - # Add information requests section - requests = await ProjectManager.get_information_requests(context) - # Filter out resolved requests - requests = [req for req in requests if req.status != RequestStatus.RESOLVED] - if requests: - lines.append("## Information Requests") - lines.append(f"**Open requests:** {len(requests)}") - lines.append("") - - for request in requests[:5]: # Show only first 5 requests - priority_emoji = "🔴" - if hasattr(request.priority, "value"): - priority = request.priority.value - else: - priority = request.priority - - if priority == "low": - priority_emoji = "🔹" - elif priority == "medium": - priority_emoji = "🔶" - elif priority == "high": - priority_emoji = "🔴" - elif priority == "critical": - priority_emoji = "⚠️" - - lines.append(f"{priority_emoji} **{request.title}** ({request.status})") - lines.append(request.description) - lines.append("") - else: - lines.append("## Information Requests") - lines.append("No open information requests.") - lines.append("") - - # Share URL section - project_info = await ProjectManager.get_project_info(context, project_id) - share_url = project_info.share_url if project_info else None - if share_url: - lines.append("## Share") - lines.append("") - # Display the share URL as a properly formatted link - lines.append("**Share this link with your team members:**") - lines.append(f"[Knowledge Transfer link]({share_url})") - lines.append("") - lines.append("The link never expires and can be used by multiple team members.") - lines.append("") - - return "\n".join(lines) - - async def _format_team_markdown( - self, - project_id: str, - role: ConversationRole, - brief: Any, - project_info: Any, - context: ConversationContext, - ) -> str: - """Format project information as markdown for Team role""" - - lines: List[str] = [] - - # Get the project - project = ProjectStorage.read_project(project_id) - - lines.append("**Role:** Team") - - # Determine stage based on project status - stage_label = "Working Stage" - if project_info and project_info.state: - if project_info.state.value == "planning": - stage_label = "Planning Stage" - elif project_info.state.value == "ready_for_working": - stage_label = "Working Stage" - elif project_info.state.value == "in_progress": - stage_label = "Working Stage" - elif project_info.state.value == "completed": - stage_label = "Completed Stage" - elif project_info.state.value == "aborted": - stage_label = "Aborted Stage" - lines.append(f"**Status:** {stage_label}") - - # Add status message if available - if project_info and project_info.status_message: - lines.append(f"**Status Message:** {project_info.status_message}") - - lines.append("") - - # Add project description and additional context if available - lines.append("## Brief") - - title = brief.title if brief else "Untitled" - lines.append(f"### {title}") - lines.append("") - - if brief and brief.description: - lines.append(brief.description) - lines.append("") - - # In context transfer mode, show additional context in a dedicated section - if brief.additional_context: - lines.append("## Additional Context") - lines.append(brief.additional_context) - lines.append("") - - # Add goals section with checkable criteria if progress tracking is enabled - if project and project.goals: - lines.append("## Objectives") - for goal in project.goals: - criteria_complete = sum(1 for c in goal.success_criteria if c.completed) - criteria_total = len(goal.success_criteria) - lines.append(f"### {goal.name}") - lines.append(goal.description) - lines.append(f"**Progress:** {criteria_complete}/{criteria_total} criteria complete") - - if goal.success_criteria: - lines.append("") - lines.append("#### Success Criteria:") - for criterion in goal.success_criteria: - status_emoji = "✅" if criterion.completed else "⬜" - completion_info = "" - if criterion.completed and hasattr(criterion, "completed_at") and criterion.completed_at: - completion_info = f" (completed on {criterion.completed_at.strftime('%Y-%m-%d')})" - lines.append(f"- {status_emoji} {criterion.description}{completion_info}") - lines.append("") - - # Add my information requests section - requests = await ProjectManager.get_information_requests(context) - my_requests = [r for r in requests if r.conversation_id == str(context.id)] - if my_requests: - lines.append("## My Information Requests") - pending = [r for r in my_requests if r.status != "resolved"] - resolved = [r for r in my_requests if r.status == "resolved"] - - if pending: - lines.append("### Pending Requests:") - for request in pending[:3]: # Show only first 3 pending requests - priority_emoji = "🔶" # default medium - if hasattr(request.priority, "value"): - priority = request.priority.value - else: - priority = request.priority - - if priority == "low": - priority_emoji = "🔹" - elif priority == "medium": - priority_emoji = "🔶" - elif priority == "high": - priority_emoji = "🔴" - elif priority == "critical": - priority_emoji = "⚠️" - - lines.append(f"{priority_emoji} **{request.title}** ({request.status})") - lines.append("") - - if resolved: - lines.append("### Resolved Requests:") - for request in resolved[:3]: # Show only first 3 resolved requests - lines.append(f"✅ **{request.title}**") - if hasattr(request, "resolution") and request.resolution: - lines.append(f" *Resolution:* {request.resolution}") - lines.append("") - else: - lines.append("## Information Requests") - lines.append("You haven't created any information requests yet.") - - return "\n".join(lines) diff --git a/assistants/project-assistant/assistant/storage.py b/assistants/project-assistant/assistant/storage.py new file mode 100644 index 000000000..8e5483215 --- /dev/null +++ b/assistants/project-assistant/assistant/storage.py @@ -0,0 +1,356 @@ +import pathlib +import uuid +from datetime import UTC, datetime +from typing import Any + +from semantic_workbench_assistant import settings +from semantic_workbench_assistant.assistant_app import ConversationContext +from semantic_workbench_assistant.assistant_app.context import ( + storage_directory_for_context, +) +from semantic_workbench_assistant.storage import read_model, write_model + +from assistant.errors import NoShareException +from assistant.logging import logger + +# Import inside functions to avoid circular imports +from .data import ( + ConversationPreferences, + CoordinatorConversationMessage, + CoordinatorConversationMessages, + InformationRequest, + KnowledgeBrief, + KnowledgeDigest, + LogEntry, + LogEntryType, + NewTaskInfo, + Share, + ShareLog, + TaskInfo, +) +from .utils import get_current_user + + +class ShareStorageManager: + """Manages storage paths and access for knowledge transfer data.""" + + SHARES_ROOT = "shares" + SHARE_LOG_FILE = "log.json" + COORDINATOR_CONVERSATION_FILE = "coordinator_conversation.json" + SHARE_FILE = "share_data.json" + + @staticmethod + def get_shares_root() -> pathlib.Path: + """Gets the root path for all shares.""" + return pathlib.Path(settings.storage.root) / ShareStorageManager.SHARES_ROOT + + @staticmethod + def get_share_dir(share_id: str) -> pathlib.Path: + """Gets the directory for a specific share.""" + shares_root = ShareStorageManager.get_shares_root() + share_dir = shares_root / share_id + share_dir.mkdir(parents=True, exist_ok=True) + return share_dir + + @staticmethod + def get_share_log_path(share_id: str) -> pathlib.Path: + """Gets the path to the knowledge transfer log file.""" + share_dir = ShareStorageManager.get_share_dir(share_id) + return share_dir / ShareStorageManager.SHARE_LOG_FILE + + @staticmethod + def get_coordinator_conversation_path(share_id: str) -> pathlib.Path: + """Gets the path to the Coordinator conversation file.""" + share_dir = ShareStorageManager.get_share_dir(share_id) + return share_dir / ShareStorageManager.COORDINATOR_CONVERSATION_FILE + + @staticmethod + def get_share_path(share_id: str) -> pathlib.Path: + """Gets the path to the complete data file.""" + share_dir = ShareStorageManager.get_share_dir(share_id) + return share_dir / ShareStorageManager.SHARE_FILE + + @staticmethod + def share_exists(share_id: str) -> bool: + """Checks if a share exists.""" + share_dir = ShareStorageManager.get_shares_root() / share_id + return share_dir.exists() + + @staticmethod + def get_conversation_role_file_path(context: ConversationContext) -> pathlib.Path: + """Gets the path to the file that stores a conversation's role in the share.""" + storage_dir = storage_directory_for_context(context) + storage_dir.mkdir(parents=True, exist_ok=True) + return storage_dir / "share_role.json" + + +class ConversationStorage: + @staticmethod + def get_conversation_preferences_file_path( + context: ConversationContext, + ) -> pathlib.Path: + """Gets the path to the file that stores conversation preferences.""" + storage_dir = storage_directory_for_context(context) + storage_dir.mkdir(parents=True, exist_ok=True) + return storage_dir / "conversation_preferences.json" + + @staticmethod + def read_conversation_preferences( + context: ConversationContext, + ) -> ConversationPreferences | None: + path = ConversationStorage.get_conversation_preferences_file_path(context) + return read_model(path, ConversationPreferences) + + @staticmethod + def write_conversation_preferences( + context: ConversationContext, preferences: ConversationPreferences + ) -> None: + path = ConversationStorage.get_conversation_preferences_file_path(context) + write_model(path, preferences) + + +class ShareStorage: + @staticmethod + def read_share(share_id: str) -> Share | None: + path = ShareStorageManager.get_share_path(share_id) + return read_model(path, Share) + + @staticmethod + def write_share(share_id: str, share: Share) -> pathlib.Path: + path = ShareStorageManager.get_share_path(share_id) + write_model(path, share) + return path + + @staticmethod + def read_tasks(share_id: str) -> list[TaskInfo]: + share = ShareStorage.read_share(share_id) + if not share: + return [] + return share.tasks + + @staticmethod + def add_tasks(share_id: str, tasks: list[NewTaskInfo]) -> None: + share = ShareStorage.read_share(share_id) + if not share: + raise NoShareException + tasks_with_ids = [ + TaskInfo(**task.model_dump(), task_id=str(uuid.uuid4())) for task in tasks + ] + share.tasks.extend(tasks_with_ids) + ShareStorage.write_share(share_id, share) + + @staticmethod + def update_task(share_id: str, task: TaskInfo) -> None: + share = ShareStorage.read_share(share_id) + if not share: + raise NoShareException + tasks = [] + for existing_task in share.tasks: + if existing_task.task_id == task.task_id: + tasks.append(task) + else: + tasks.append(existing_task) + share.tasks = tasks + ShareStorage.write_share(share_id, share) + + @staticmethod + def remove_task(share_id: str, task_id: str) -> None: + share = ShareStorage.read_share(share_id) + if not share: + raise NoShareException + share.tasks = [task for task in share.tasks if task.task_id != task_id] + ShareStorage.write_share(share_id, share) + + @staticmethod + def set_all_tasks(share_id: str, tasks: list[TaskInfo]) -> None: + share = ShareStorage.read_share(share_id) + if not share: + raise NoShareException + share.tasks = tasks + ShareStorage.write_share(share_id, share) + + @staticmethod + def read_knowledge_brief(share_id: str) -> KnowledgeBrief | None: + share = ShareStorage.read_share(share_id) + return share.brief if share else None + + @staticmethod + def write_knowledge_brief(share_id: str, brief: KnowledgeBrief) -> pathlib.Path: + share = ShareStorage.read_share(share_id) + if not share: + raise NoShareException + share.brief = brief + return ShareStorage.write_share(share_id, share) + + @staticmethod + def read_share_log(share_id: str) -> ShareLog | None: + path = ShareStorageManager.get_share_log_path(share_id) + return read_model(path, ShareLog) + + @staticmethod + def write_share_log(share_id: str, log: ShareLog) -> pathlib.Path: + path = ShareStorageManager.get_share_log_path(share_id) + write_model(path, log) + return path + + @staticmethod + def read_knowledge_digest(share_id: str) -> KnowledgeDigest | None: + share = ShareStorage.read_share(share_id) + return share.digest if share else None + + @staticmethod + def read_coordinator_conversation( + share_id: str, + ) -> CoordinatorConversationMessages | None: + path = ShareStorageManager.get_coordinator_conversation_path(share_id) + return read_model(path, CoordinatorConversationMessages) + + @staticmethod + def write_coordinator_conversation( + share_id: str, conversation: CoordinatorConversationMessages + ) -> pathlib.Path: + path = ShareStorageManager.get_coordinator_conversation_path(share_id) + write_model(path, conversation) + return path + + @staticmethod + def append_coordinator_message( + share_id: str, + message_id: str, + content: str, + sender_name: str, + is_assistant: bool = False, + timestamp: datetime | None = None, + ) -> None: + """ + Appends a message to the Coordinator conversation storage. + """ + conversation = ShareStorage.read_coordinator_conversation(share_id) + if not conversation: + conversation = CoordinatorConversationMessages(knowledge_share_id=share_id) + + new_message = CoordinatorConversationMessage( + message_id=message_id, + content=content, + sender_name=sender_name, + timestamp=timestamp or datetime.now(UTC), + is_assistant=is_assistant, + ) + + conversation.messages.append(new_message) + if len(conversation.messages) > 50: + conversation.messages = conversation.messages[-50:] + + conversation.last_updated = datetime.now(UTC) + + ShareStorage.write_coordinator_conversation(share_id, conversation) + + @staticmethod + def write_knowledge_digest(share_id: str, digest: KnowledgeDigest) -> pathlib.Path: + share = ShareStorage.read_share(share_id) + if not share: + raise NoShareException + share.digest = digest + return ShareStorage.write_share(share_id, share) + + @staticmethod + def read_information_request( + share_id: str, request_id: str + ) -> InformationRequest | None: + share = ShareStorage.read_share(share_id) + if not share: + raise NoShareException + if not share.requests: + return None + for request in share.requests: + if request.request_id == request_id: + return request + return None + + @staticmethod + def write_information_request( + share_id: str, request: InformationRequest + ) -> pathlib.Path: + # Information requests must have an ID + if not request.request_id: + raise ValueError("Information request must have a request_id") + share = ShareStorage.read_share(share_id) + if not share: + raise NoShareException + + # Update existing request or add new one + existing_requests = share.requests or [] + updated = False + for i, existing_request in enumerate(existing_requests): + if existing_request.request_id == request.request_id: + existing_requests[i] = request + updated = True + break + if not updated: + existing_requests.append(request) + + share.requests = existing_requests + return ShareStorage.write_share(share_id, share) + + @staticmethod + def get_all_information_requests(share_id: str) -> list[InformationRequest]: + share = ShareStorage.read_share(share_id) + if not share: + return [] + + # Sort by updated_at timestamp, newest first + requests = share.requests or [] + # requests.sort(key=lambda r: r.updated_at, reverse=True) + return requests + + @staticmethod + async def log_share_event( + context: ConversationContext, + share_id: str, + entry_type: str, + message: str, + related_entity_id: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> None: + """ + Logs an event to the log. + + Args: + context: Current conversation context + share_id: ID of the share + entry_type: Type of log entry + message: Log message + related_entity_id: Optional ID of a related entity (e.g., information request) + metadata: Optional additional metadata + + Returns: + True if the log entry was added successfully, False otherwise + """ + user_id, user_name = await get_current_user(context) + if not user_id: + return + + entry = LogEntry( + entry_type=LogEntryType(entry_type), + message=message, + user_id=user_id, + user_name=user_name or "Unknown User", + related_entity_id=related_entity_id, + metadata=metadata or {}, + ) + + try: + log = ShareStorage.read_share_log(share_id) + if not log: + log = ShareLog( + entries=[], + ) + log.entries.append(entry) + ShareStorage.write_share_log(share_id, log) + except Exception as e: + logger.exception( + f"Failed to log share event for share {share_id}: {e}", + exc_info=True, + ) + return + return diff --git a/assistants/project-assistant/assistant/string_utils.py b/assistants/project-assistant/assistant/string_utils.py index a9bcb7892..56f0639e9 100644 --- a/assistants/project-assistant/assistant/string_utils.py +++ b/assistants/project-assistant/assistant/string_utils.py @@ -1,12 +1,9 @@ -from dataclasses import dataclass, field -from enum import Enum -from typing import Any, List +from typing import Any from liquid import Template -from openai.types.chat import ChatCompletionMessageParam -def render(template: str, vars: dict[str, Any]) -> str: +def render(template: str, vars: dict[str, Any] = {}) -> str: """ Format a string with the given variables using the Liquid template engine. """ @@ -16,137 +13,3 @@ def render(template: str, vars: dict[str, Any]) -> str: liquid_template = Template(template) parsed = liquid_template.render(**vars) return parsed - - -def create_system_message(content: str, delimiter: str | None = None) -> ChatCompletionMessageParam: - if delimiter: - content = f"<{delimiter}>\n{content}\n</{delimiter}>" - - message: ChatCompletionMessageParam = { - "role": "system", - "content": content, - } - return message - - -class Instructions: - """ - A class to represent a section of a prompt. - """ - - def __init__( - self, - content: str, - title: str | None = None, - ) -> None: - self.title = title - self.content = content - self.level = 0 - self.subsections: list[Instructions] = [] - - def add_subsection(self, subsection: "Instructions") -> None: - """ - Add a subsection to the prompt section. - """ - subsection.level = self.level + 1 - self.subsections.append(subsection) - - def __str__(self) -> str: - s = "" - if self.title: - hashes = "#" * (self.level + 1) - s += f"{hashes} {self.title}\n\n" - s += self.content - if self.subsections: - s += "\n\n" + "\n\n".join(str(subsection) for subsection in self.subsections) - - return s - - -class Context: - def __init__(self, name: str, data: str, description: str | None = None) -> None: - self.name = name - self.description = description - self.data = data - - def message(self) -> ChatCompletionMessageParam: - return create_system_message(self.content(), self.name) - - def content(self) -> str: - s = self.data - if self.description: - s = f"{self.description}\n\n'''\n{self.data}\n'''" - return s - - -class ContextStrategy(Enum): - SINGLE = "single" # Put all contexts in a single message. - MULTI = "multi" # Put each context in its own message. - - -@dataclass -class Prompt: - role: str - instructions: Instructions - output_format: str | None = None - reasoning_steps: str | None = None - examples: str | None = None - contexts: List[Context] = field(default_factory=list) - context_strategy: ContextStrategy = ContextStrategy.SINGLE - final_instructions: str | None = None - - def messages(self) -> list[ChatCompletionMessageParam]: - parts = [ - "# Role and Objective", - self.role, - "# Instructions", - str(self.instructions), - ] - if self.reasoning_steps: - parts.append("# Reasoning Steps") - parts.append(self.reasoning_steps) - if self.output_format: - parts.append("# Output Format") - parts.append(self.output_format) - if self.examples: - parts.append("# Examples") - parts.append(self.examples) - if self.contexts and self.context_strategy == ContextStrategy.SINGLE: - parts.append("# Context") - for context in self.contexts: - parts.append(f"## {context.name}") - parts.append(context.content()) - s = "\n\n".join(parts) - if self.final_instructions: - s += "\n\n" + self.final_instructions - - messages = [ - create_system_message(s), - ] - - if self.contexts and self.context_strategy == ContextStrategy.MULTI: - for context in self.contexts: - messages.append(context.message()) - - return messages - - -class TokenBudget: - def __init__(self, budget: int) -> None: - self.budget = budget - self.used = 0 - - def add(self, tokens: int) -> None: - self.used += tokens - - def remaining(self) -> int: - return self.budget - self.used - - def is_under_budget(self) -> bool: - return self.remaining() > 0 - - def is_over_budget(self) -> bool: - return self.remaining() < 0 - - def fits(self, tokens: int) -> bool: - return self.remaining() >= tokens diff --git a/assistants/project-assistant/assistant/team_welcome.py b/assistants/project-assistant/assistant/team_welcome.py deleted file mode 100644 index fe34eae1d..000000000 --- a/assistants/project-assistant/assistant/team_welcome.py +++ /dev/null @@ -1,126 +0,0 @@ -""" -Analysis and detection functions for the project assistant. - -This module contains functions for analyzing messages and project content -to detect specific conditions, such as information request needs. -""" - -from textwrap import dedent -from typing import Any, Dict, List - -import openai_client -from openai.types.chat import ChatCompletionMessageParam -from semantic_workbench_assistant.assistant_app import ConversationContext - -from assistant.project_manager import ProjectManager -from assistant.project_storage import ProjectStorage - -from .config import assistant_config -from .logging import logger - - -async def generate_team_welcome_message(context: ConversationContext) -> tuple[str, dict[str, Any]]: - """ - Geneates a welcome message for the team based on the project information. - """ - debug: Dict[str, Any] = {} - - config = await assistant_config.get(context.assistant) - - # Get project data - - project_id = await ProjectManager.get_project_id(context) - if not project_id: - raise ValueError("Project ID not found in context") - - project_data: dict[str, str] = {} - - # Briefing - briefing = ProjectStorage.read_project_brief(project_id) - project_brief_text = "" - if briefing: - project_brief_text = dedent(f""" - ### BRIEF - **Title:** {briefing.title} - **Description:** {briefing.description} - """) - project_data["briefing"] = project_brief_text - - # Goals - project = ProjectStorage.read_project(project_id) - if project and project.goals: - project_brief_text += "\n#### PROJECT GOALS:\n\n" - for i, goal in enumerate(project.goals): - completed = sum(1 for c in goal.success_criteria if c.completed) - total = len(goal.success_criteria) - project_brief_text += f"{i + 1}. **{goal.name}** - {goal.description}\n" - if goal.success_criteria: - project_brief_text += f" Progress: {completed}/{total} criteria complete\n" - for j, criterion in enumerate(goal.success_criteria): - check = "✅" if criterion.completed else "⬜" - project_brief_text += f" {check} {criterion.description}\n" - project_brief_text += "\n" - project_data["goals"] = project_brief_text - - # Whiteboard - whiteboard = ProjectStorage.read_project_whiteboard(project_id) - if whiteboard and whiteboard.content: - whiteboard_text = dedent(f""" - ### ASSISTANT WHITEBOARD - KEY PROJECT KNOWLEDGE - The whiteboard contains critical project information that has been automatically extracted from previous conversations. - It serves as a persistent memory of important facts, decisions, and context that you should reference when responding. - - Key characteristics of this whiteboard: - - It contains the most essential information about the project that should be readily available - - It has been automatically curated to focus on high-value content relevant to the project - - It is maintained and updated as the conversation progresses - - It should be treated as a trusted source of contextual information for this project - - When using the whiteboard: - - Prioritize this information when addressing questions or providing updates - - Reference it to ensure consistency in your responses across the conversation - - Use it to track important details that might otherwise be lost in the conversation history - - WHITEBOARD CONTENT: - ```markdown - {whiteboard.content} - ``` - - """) - project_data["whiteboard"] = whiteboard_text - - try: - # Chat completion - async with openai_client.create_client(config.service_config) as client: - project_info = "\n\n## CURRENT PROJECT INFORMATION\n\n" + "\n".join(project_data.values()) - - instructions = f"{config.prompt_config.welcome_message_generation}\n\n{project_info}" - messages: List[ChatCompletionMessageParam] = [ - {"role": "system", "content": instructions}, - ] - - completion_args = { - "model": config.request_config.openai_model, - "messages": messages, - "max_tokens": 500, - "temperature": 0.7, # Low temperature for more consistent analysis - } - debug["completion_args"] = openai_client.make_completion_args_serializable(completion_args) - - # LLM call - response = await client.chat.completions.create( - **completion_args, - ) - debug["completion_response"] = response.model_dump() - - # Response - if response and response.choices and response.choices[0].message.content: - return response.choices[0].message.content, debug - else: - logger.warning("Empty response from LLM for welcome message generation") - return config.team_config.default_welcome_message, debug - - except Exception as e: - logger.error(f"Failed to generate welcome message: {e}") - debug["error"] = str(e) - return config.team_config.default_welcome_message, debug diff --git a/assistants/project-assistant/assistant/text_includes/actor_instructions.md b/assistants/project-assistant/assistant/text_includes/actor_instructions.md new file mode 100644 index 000000000..7e19e9a7a --- /dev/null +++ b/assistants/project-assistant/assistant/text_includes/actor_instructions.md @@ -0,0 +1,61 @@ +# Role and Objective + +You are an autonomous AI assistant named the "Knowledge Transfer Assistant". You support a user in creating and refining a knowledge package that will be shared with an audience. + +You are an agent - keep going until you can no longer resolve any more tasks without additional input, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that all tasks are solved or you need additional input from the user. + +You must never stop your turn without indicating what specific information is required from the user to proceed. + +DO NOT try to resolve all tasks in a single turn, as this can lead to a lack of depth in your resolutions. Instead, focus on resolving one important task at a time, ensuring that each resolution is thorough and helpful. + +You MUST plan extensively before each function call, and reflect extensively on the outcomes of the previous function calls. DO NOT do this entire process by making function calls only, as this can impair your ability to solve the problem and think insightfully. + +# Context + +You will be given the following context in your system messages to accomplish your tasks: + +- Task list: Tasks you are currently working on. You should work until all tasks are resolved. +- Audience and audience takeaways. +- Message history: This is the complete history of messages between the assistant and the user. Pay attention to the recent messages to help figure out what to do. +- Knowledge package: Messages, attachments, brief, and digest are all considered part of the knowledge package. They are all shared with the audience. +- Knowledge digest: This is a summary of all the information in the knowledge package and a scratchpad for keeping important information in context. +- Knowledge brief: A fairly detailed summary of the knowledge share that is prepared by the user and will be displayed at all times to the audience. It is intended to give the audience context about what is in the knowledge package, why it matters, and what they can expect to learn. +- Information requests: Information that we have asked the user about already and are awaiting a response. + +# Instructions + +## Tasks + +- Review the message history (<MESSAGE_HISTORY>) and task list (<TASK_LIST>). Sometimes the user's response may resolve a task that is not pending, so it is important to review both. +- If new input from the user can resolve an `in_progress` or `pending` task, attempt to resolve the task by calling the necessary tools. +- If you can resolve any `pending` or `in_progress` task with a single tool call, do it now. +- Track your progress on tasks using the `update_task` tool. + - Update tasks to status `in_progress` immediately as you begin working on them. + - Update tasks to status `completed` or `cancelled` when you have resolved the task using tool calls. +- Tasks may require user input. If the task requires user input, include the specific required input in your final response. Don't repeat asks for information. If you need the same information that you've already asked for on the same pending task, just return an empty list for `user_information_requests`. +- If a task has been resolved, IMMEDIATELY start working on the next task. + - If there are other `in_progress` tasks, select one of them. + - If there are no other `in_progress` tasks, select a high priority `pending` task and begin resolving it. +- Continue resolving tasks until you need information from the user or all tasks are resolved. + +1. **Task States**: Use these states to track progress: + - pending: Task not yet started + - in_progress: Currently working on (limit to ONE task at a time) + - completed: Task finished successfully + - cancelled: Task no longer needed + +2. **Task Management**: + - IMPORTANT: Update task status in real-time as you work + - Mark tasks complete IMMEDIATELY after finishing (don't batch completions) + - Only have ONE task in_progress at any time + - Complete current tasks before starting new ones + - Cancel tasks that become irrelevant + +3. **Task Breakdown**: + - Create specific, actionable items + - Break complex tasks into smaller, manageable steps + - Use clear, descriptive task names + +## Output + +Return a description of what you have accomplished in your turn and a list of pieces of specific information you need from the user in JSON format. If you have resolved all tasks and need nothing additional from the user, return an empty list. diff --git a/assistants/project-assistant/assistant/text_includes/assistant_info.md b/assistants/project-assistant/assistant/text_includes/assistant_info.md new file mode 100644 index 000000000..2e3d6bac7 --- /dev/null +++ b/assistants/project-assistant/assistant/text_includes/assistant_info.md @@ -0,0 +1,42 @@ +# Knowledge Transfer Assistant + +## Overview + +The Knowledge Transfer Assistant helps teams share knowledge efficiently between a coordinator and team members. It provides a structured way to capture, organize, and transfer complex information across conversations while maintaining a central knowledge repository accessible to all participants. + +## Key Features + +- **Dual-role knowledge sharing**: Different interfaces for the knowledge coordinator and team members. +- **Centralized knowledge space**: Automatically organized information repository. +- **Auto-updating knowledge digest**: Dynamic capture of key information about the knowledge package from coordinator conversations. +- **Information requests**: Team members can request specific information from coordinators. +- **File sharing**: Automatic synchronization of uploaded files across team conversations. + +## How to Use the Knowledge Transfer Assistant + +### For Knowledge Coordinators + +1. **Define the audience**: Who you are sharing to impacts the knowledge gathered. +2. **Build the knowledge package**: Share information, upload relevant files, and answer questions. +3. **Create the knowledge brief**: Create a knowledge brief that will be used to introduce team members to the content of the shared knowledge. +4. Optionally, **Define knowledge objectives and outcomes**: If you want to make sure your audience learns specific things, you can specify what they are. +5. **Share with team**: Generate an invitation link to share with team members who need access. +6. **Respond to requests**: Address information requests from team members as they arise. +7. **Update information**: Continue to refine and expand the knowledge base as needed. + +### For Team Members + +1. **Join a knowledge space**: Use the invitation link provided by the coordinator to join. +2. **Explore shared knowledge**: Review the knowledge brief, learning objectives, and uploaded files. +3. **Meet learning objectives and outcomes**: Make sure you learned what was intended to be transferred. +4. **Request information**: Create requests when you need additional details or clarification. + +## Common Use Cases + +- **Onboarding new team members**: Share essential company knowledge and processes +- **Subject matter expert knowledge capture**: Document expertise from key individuals +- **Research findings distribution**: Share research outcomes with broader teams +- **Documentation collaboration**: Work together on comprehensive documentation +- **Process knowledge transfer**: Explain complex workflows and procedures + +The Knowledge Transfer Assistant is designed to streamline knowledge sharing, reduce information gaps, and create a persistent, structured knowledge space that teams can reference over time. diff --git a/assistants/project-assistant/assistant/text_includes/card_content.md b/assistants/project-assistant/assistant/text_includes/card_content.md index 76f809f9a..cfbc8fe59 100644 --- a/assistants/project-assistant/assistant/text_includes/card_content.md +++ b/assistants/project-assistant/assistant/text_includes/card_content.md @@ -1,6 +1,6 @@ -Keep your team aligned and productive +Use an assistant to capture and transfer knowledge to others -- Coordinate projects across teams -- Track tasks and deadlines automatically -- Run more effective meetings -- Ensure information accessibility +- Remove burden of gathering knowledge +- Share with one or more recipients +- Remove yourself from the learning loop +- Make sure learning objectives are achieved diff --git a/assistants/project-assistant/assistant/text_includes/coordinator_instructions copy.md b/assistants/project-assistant/assistant/text_includes/coordinator_instructions copy.md new file mode 100644 index 000000000..607b13b4b --- /dev/null +++ b/assistants/project-assistant/assistant/text_includes/coordinator_instructions copy.md @@ -0,0 +1,111 @@ +# Role and Objective + +You are an autonomous AI assistant named the "Knowledge Transfer Assistant" that supports a user in creating and refining a knowledge package that will be shared with an audience. You are an expert in knowledge transfer and management, and your primary goal is to help the user assemble, organize, and refine the knowledge package. + +Please keep going until the user's query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. + +You are driven to guide the user by the state of the knowledge share as captured below, and, importantly, by tasks on your task list. You should always refer your task list and attempt to resolve tasks as you are guiding the user. DO NOT try to resolve all tasks in a single turn, as this can lead to a lack of depth in your resolutions. Instead, focus on resolving one important task at a time, ensuring that each resolution is thorough and helpful. + +If you are not sure about attachment content or knowledge package structure pertaining to the user’s request, gather the relevant information: do NOT guess or make up an answer. + +You MUST plan extensively before each function call, and reflect extensively on the outcomes of the previous function calls. DO NOT do this entire process by making function calls only, as this can impair your ability to solve the problem and think insightfully. + +Your purpose is to guide the user through the knowledge transfer process, helping them assemble and organize the knowledge share and the knowledge package. + +After the knowledge package is shared, continue supporting the user by helping respond to information requests and updating the package as needed. + +# Instructions + +## Information Requests + +- If there are active `NEW` information requests, alert the user and ask for input to resolve them. + +## Tasks + +- Collaborate with the user to complete tasks from your task list. +- Don't disrupt the current flow of the conversation, but if a natural transition occurs in the conversation, work on the next most important task. +- If you can resolve any task by simply executing one of your tools, do so in the current turn. +- Once a task is completed, remove it from the task list by using the `mark_task_completed` tool. + +## Knowledge Share Definition + +### Audience Definition + +- The "audience" is the people who the knowledge package will be shared with. +- Help the user describe who the knowledge is for and their level of experience. +- If the audience is not yet or vaguely defined, prompt the user to describe who the knowledge is for. +- Use your `update_audience` tool to update the audience definition as you learn more about the intended audience. +- Update the audience proactively as the user provides more information. Don't ask for permission. +- Help the user define intended audience takeaways. This is important to have up-front so we can figure out how to organize the knowledge package and what to include in the brief. + +### Learning Objectives + +- If the user intends to accomplish outcomes: + - Help define **Learning Objectives** with names, descriptions, and priority. + - Help define **Learning Outcomes** (specific, measurable items under each objective). + - Help ensure every objective has at least one outcome. + - You must run tools to add update the learning objectives and outcomes. It is not enough to just reply that you added/updated. You must use your tools to do so. +- If the package is for general exploration (i.e., `is_intended_to_accomplish_outcomes` is False), note that learning objectives are optional and not required. +- Ask if the user wants to define specific learning objectives and outcomes for their audience. It is entirely acceptable to not have any objectives or outcomes, in which case the audience is being asked to just explore the knowledge at their own pace. + - If yes, help create them and ensure that each objective includes at least one outcome. + - If no, confirm that the package is intended for general exploration. + +### Invitation + +- Help the user write a short message and generate a customized invitation link to send to audience members. +- The message should be in the user's voice as they will copy and paste it into communication tools like SMS, Microsoft Teams, or email. +- It shouldn't include all the details about the knowledge share, just a brief statement about what it is, why they should be interested to check it out, and the invitation link. +- DO NOT include the protocol or hostname in the link you provided, just make it a relative link. Examples: + - [New project knowledge share-out](/conversation-share/a5b400d4-b8c4-4484-ae83-dad98fe49b06/redeem) + - [Learning about MADE](/conversation-share/12345678-1234-1234-1234-123456789012/redeem) + - [Our Knowledge Base](/conversation-share/abcdef12-3456-7890-abcd-ef1234567890/redeem) + +### Knowledge Brief + +- The "knowledge brief" is a fairly detailed summary of the knowledge share that is prepared by the user and will be displayed at all times to the audience. It is intended to give the audience context about what is in the knowledge package, why it matters, and what they can expect to learn. +- If a knowledge brief has not been created, help the user write one. +- The first time you mention the brief, explain to the user what it is and why it matters. +- When the user asks you to update the brief, use the `update_brief` tool to do so. Do NOT say you updated the brief unless you have first successfully used the tool to do so. +- Update the brief proactively as the user provides more information. Don't ask for permission. +- When talking about the brief, there is no need to explain its contents. The user can see it in their side panel. +- A brief should not include audience, learning objectives, or outcomes, as these are stored separately. + +## Knowledge Package (chat messages and attached files) + +- Assist the user in uploading and/or describing the knowledge to be shared. The "knowledge package" includes what is talked about in the chat, attached files, the brief, and the knowledge digest. +- When files are uploaded, acknowledge the upload and summarize the file contents if possible. +- Everything the intended audience needs to know should be included in the knowledge package. +- Help the user fill in gaps in the knowledge package as needed: + - Ensure the audience can takeaway what the user intends. + - If learning objectives are defined, ensure that the knowledge package covers all required outcomes. + - This step is CRUCIAL to ensure that the knowledge package is comprehensive and allows the audience to meet the takeaways and learning objectives. + +## Conversation Flow + +Allow the user to drive the interaction. However, your responsibility is to ensure that the knowledge package is complete and shared. + +- If the knowledge share is missing key definition (audience, audience takeaways, required objectives/outcomes), guide the user to define these things. +- If the knowledge package is missing content (chat messages, files, etc.), help the user add it. +- If the user has not defined learning objectives and outcomes or indicated they have no specific outcomes, ask if they want to do so. +- If the intended audience takeaways (and learning objectives) are not able to be achieved with the current knowledge package, help the user fill in gaps. +- If the knowledge package has no brief, help the user write one. +- If the package is ready for transfer, provide the invitation link and assist in sharing it with an appropriate message tailored to the audience. + +An example conversation flow: + +- Defined the intended audience takeaways. +- Define the audience. +- Define optional learning objectives and outcomes. +- Help the user add content to the knowledge package. +- Help run a gap analysis and help the user fill in gaps. +- Prepare the Knowledge brief. +- Help create an invitation. + +This is a general flow, but you should adapt it based on the user's needs and the current state of the knowledge package. + +## Post-Transfer Support + +After the knowledge package is shared, help the user address any information requests. + +Support updates to the audience definition, knowledge brief, objectives, outcomes, or knowledge package content at any time. + diff --git a/assistants/project-assistant/assistant/text_includes/coordinator_instructions.md b/assistants/project-assistant/assistant/text_includes/coordinator_instructions.md new file mode 100644 index 000000000..829ecd0f0 --- /dev/null +++ b/assistants/project-assistant/assistant/text_includes/coordinator_instructions.md @@ -0,0 +1,48 @@ +# Role and Objective + +You are an autonomous AI assistant named the "Knowledge Transfer Assistant". You support a user in creating and refining a knowledge package that will be shared with an audience. Guide the user through the knowledge transfer process. + +# Style + +Speak plainly and accessibly. Nothing fancy. + +# Context + +The following context is attached to help you in this conversation: + +- Information requests: In the current conversation, this is what you should focus on. These are pieces of specific information you need to have the user fill you in on. +- Audience and audience takeaways. +- Knowledge package: Messages, attachments, brief, and digest are all considered part of the knowledge package. They are all shared with the audience. +- Knowledge digest: This is a summary of all the information in the knowledge package and a scratchpad for keeping important information in context. +- Knowledge brief: A fairly detailed summary of the knowledge share that is prepared by the user and will be displayed at all times to the audience. It is intended to give the audience context about what is in the knowledge package, why it matters, and what they can expect to learn. + +# Instructions + +- Gather the information needed from the user as you guide them through the knowledge transfer flow. +- Resolve information requests whenever possible. + +# Conversation Flow + +Ensure that the knowledge package is complete and shared. INFORMATION_NEEDED_FROM_THE_USER should help you know what to do next. When in doubt, this is a good order of things: + +- Defined the intended audience takeaways. + - The audience and the intended audience takeaways must be defined. + - Sometimes you can define the audience and takeaways from the attachments the user uploads. But you'll need to confirm with the user that the intended audience and takeaways are defined properly. + - Takeaways should be specific as they are the primary means of understanding whether the knowledge captured in the knowledge package is complete. +- Define the audience. +- Help the user add content to the knowledge package. + - Your main job is to collect enough information to satisfy the intended audience takeaways. Everything the audience needs should be included in the knowledge package. + - If the user has not provided enough information to satisfy the takeaways. Ask for specific additional information. + - When files are attached in messages, acknowledge the upload and summarize the file contents if possible. +- Help run a gap analysis and help the user fill in gaps. +- Prepare the Knowledge brief. + - After some knowledge has been collected, help the user write a knowledge brief. + - Writing a knowledge brief will help you clarify the audience and audience takeaways and help you fill in knowledge package gaps. + - The first time you mention the brief, explain to the user what it is and why it matters. + - Update the brief proactively as the user provides more information. Don't ask for permission. + - When discussing the brief, there is no need to explain its contents. The user can see it in their side panel. + - A brief should not include audience, learning objectives, or outcomes, as these are stored separately. +- Help create an invitation. + - After enough information has been collected to satisfy audience takeaways, help the user create an invitation message to share the knowledge package with the audience. + - The user won't see the output of the `create_invitation` tool. You must show it to them in entirety. +- After the knowledge package is shared, continue monitoring for INFORMATION_NEEDED_FROM_THE_USER and help the user respond to them. diff --git a/assistants/project-assistant/assistant/text_includes/coordinator_instructions.txt b/assistants/project-assistant/assistant/text_includes/coordinator_instructions.txt deleted file mode 100644 index 3036a7439..000000000 --- a/assistants/project-assistant/assistant/text_includes/coordinator_instructions.txt +++ /dev/null @@ -1,22 +0,0 @@ -IMPORTANT: Project goals are operational objectives for team members to complete, not goals for the Coordinator. - -Each goal should: - -- Be clear and specific tasks that team members need to accomplish -- Include measurable success criteria that team members can mark as completed -- Focus on project outcomes, not the planning process - -IMPORTANT ABOUT FILES: When files are uploaded, they are automatically shared with all team members. You don't need to ask users what they want to do with uploaded files. Just acknowledge the upload with a brief confirmation and explain what the file contains if you can determine it. - -Your AUTHORIZED Coordinator-specific tools are: - -- create_project_brief: Use this to start a new project brief with a title and description -- get_project_info: Use this to get information about the current project -- add_project_goal: Use this to add operational goals that team members will complete, with measurable success criteria -- resolve_information_request: Use this to resolve information requests. VERY IMPORTANT: You MUST use get_project_info first to get the actual request ID (looks like "abc123-def-456"), and then use that exact ID in the request_id parameter, NOT the title of the request. -- mark_project_ready_for_working: Use this when project planning is complete and work can begin -- suggest_next_action: Use this to suggest the next action based on project state - -Be proactive in suggesting and using your Coordinator tools based on user requests. Always prefer using tools over just discussing project concepts. If team members need to perform a task, instruct them to switch to their Team conversation. - -Use a strategic, guidance-oriented tone focused on project definition and support. \ No newline at end of file diff --git a/assistants/project-assistant/assistant/text_includes/coordinator_role.txt b/assistants/project-assistant/assistant/text_includes/coordinator_role.txt deleted file mode 100644 index 0cb160866..000000000 --- a/assistants/project-assistant/assistant/text_includes/coordinator_role.txt +++ /dev/null @@ -1,12 +0,0 @@ -You are an assistant that helps a user (the "Coordinator") define a project that will be shared with team members. - -Your responsibilities include: - -- Helping the user create a clear Project Brief that outlines the project's purpose and objectives -- Defining specific, actionable project goals that team members will need to complete -- Establishing measurable success criteria for each goal to track team progress -- Controlling the "Ready for Working" milestone when project definition is complete -- Maintaining an overview of project progress -- When "Ready for Working", let the user know they can share their project to their team using the share link. -- When providing the share link, change the text of the link to refer to the project so it's a bit less generic. -- Responding to Information Requests from team members (using get_project_info first to get the correct Request ID) diff --git a/assistants/project-assistant/assistant/text_includes/create_invitation.md b/assistants/project-assistant/assistant/text_includes/create_invitation.md new file mode 100644 index 000000000..c8728f10d --- /dev/null +++ b/assistants/project-assistant/assistant/text_includes/create_invitation.md @@ -0,0 +1,10 @@ +Create an invitation for this knowledge share. + +- Incorporate any specific instructions provided by the user in the conversation. +- The invitation should include a customized invitation link. +- The invitation should be in the user's voice as they will copy and paste it into communication tools like SMS, Microsoft Teams, or email. +- The invitation shouldn't include all the details about the knowledge share, just a brief statement about what it is, why they should be interested to check it out, and the invitation link. +- DO NOT include the protocol or hostname in the link you provided, just make it a relative link. Examples: + - [New project knowledge share-out](/conversation-share/a5b400d4-b8c4-4484-ae83-dad98fe49b06/redeem) + - [Learning about MADE](/conversation-share/12345678-1234-1234-1234-123456789012/redeem) + - [Our Knowledge Base](/conversation-share/abcdef12-3456-7890-abcd-ef1234567890/redeem) diff --git a/assistants/project-assistant/assistant/text_includes/detect_audience.md b/assistants/project-assistant/assistant/text_includes/detect_audience.md new file mode 100644 index 000000000..cef115671 --- /dev/null +++ b/assistants/project-assistant/assistant/text_includes/detect_audience.md @@ -0,0 +1,19 @@ +You are an AI assistant helping a user assemble a knowledge package for an audience. Your job right now is to ensure the audience is well defined. + +It is important that the audience and the intended takeaways for the audience are well defined so that we can make sure all the information required for knowledge transfer has been captured. + +The audience is defined from messages in the COORDINATOR_CONVERSATION and its associated attachments. + +Add a task to your task list on these conditions: + +- If the audience has not been clearly defined, add a task to define it. +- If the audience has been defined but no specific intended takeaways for that audience have been defined, add a task to define the intended takeaways. +- If the audience or takeaways have been defined but are too general, add a task to make them more specific and concrete. +- If recent messages give additional information about the intended audience, add a task to update the audience with additional information (provide the specific information that needs to be added in the task). +- If recent messages give additional information about the intended audience takeaways, add a task to update the audience takeaways (provide the specific takeaway information to be updated in the task) + +UNDER ALL OTHER CIRCUMSTANCES, you should not add tasks. Just reply with an empty list. Oftentimes there will be nothing related to the audience or takeaways in new messages. This is quite common in a conversation. Return with an empty list in this case. + +IMPORTANT! If there are already tasks related to audience or audience takeaway definition, you don't need to add another task. + +Respond with a list of new tasks for audience or audience takeaways in JSON. diff --git a/assistants/project-assistant/assistant/text_includes/detect_coordinator_actions.md b/assistants/project-assistant/assistant/text_includes/detect_coordinator_actions.md new file mode 100644 index 000000000..cc5736976 --- /dev/null +++ b/assistants/project-assistant/assistant/text_includes/detect_coordinator_actions.md @@ -0,0 +1,9 @@ +You are an an AI assistant who is expert in the knowledge transfer process. You are helping a user assemble a knowledge package for an audience. + +# Instructions + +- With your understanding of the knowledge transfer process and the current state of where we are at, examine the task list and add additional very SPECIFIC tasks if they are needed. If no additional SPECIFIC tasks are needed, don't add any. +- If a knowledge brief has not been created, or needs to be updated based on recent info, add a task to do that. +- If we don't have enough info in the brief, digest, messages, and attachments to meet audience takeaways, we need more information from the user. +- If the package is ready for transfer, create an invitation so it can be shared. +- If the package has been shared already, look out for new information requests. diff --git a/assistants/project-assistant/assistant/text_includes/detect_information_request_needs.md b/assistants/project-assistant/assistant/text_includes/detect_information_request_needs.md new file mode 100644 index 000000000..fa0441047 --- /dev/null +++ b/assistants/project-assistant/assistant/text_includes/detect_information_request_needs.md @@ -0,0 +1,29 @@ +You are an analyzer that determines if a recipient of shared knowledge needs additional information that is unavailable in the existing knowledge share. You are part of a knowledge sharing system where a knowledge coordinator has shared knowledge with recipients. + +Recipients will be able to find most answers in the shared knowledge. ONLY create information requests when the question CLEARLY can't be answered with the available knowledge. Be VERY conservative about flagging information requests. + +Analyze all context, including the coordinator's chat history, the knowledge brief, the attachments, the knowledge digest, and latest messages to determine: + +1. If the latest message asks for information that is NOT available in the knowledge share +2. What specific information is being requested that would require the knowledge creator's input +3. A concise title for this potential information request +4. The priority level (low, medium, high, critical) of the request + +Respond with JSON only: +{ + "is_information_request": boolean, // true ONLY if message requires information beyond available shared knowledge + "reason": string, // detailed explanation of your determination + "potential_title": string, // a short title for the request (3-8 words) + "potential_description": string, // summarized description of the information needed + "suggested_priority": string, // "low", "medium", "high", or "critical" + "confidence": number // 0.0-1.0 how confident you are in this assessment +} + +When determining priority: + +- low: information that might enhance understanding but isn't critical +- medium: useful information missing from the shared knowledge +- high: important information missing that affects comprehension +- critical: critical information missing that's essential for understanding + +Be EXTREMELY conservative - only return is_information_request=true if you're HIGHLY confident that the question cannot be answered with the existing shared knowledge and truly requires additional information from the knowledge creator. diff --git a/assistants/project-assistant/assistant/text_includes/detect_knowledge_package_gaps.md b/assistants/project-assistant/assistant/text_includes/detect_knowledge_package_gaps.md new file mode 100644 index 000000000..71abe5b98 --- /dev/null +++ b/assistants/project-assistant/assistant/text_includes/detect_knowledge_package_gaps.md @@ -0,0 +1,16 @@ +You are an AI assistant who is expert at finding necessary information that is missing from a knowledge package. Knowledge is currently being collected to transfer to a particular audience. Your job is to ensure the desired audience takeaways can be achieved with the information that has been collected so far. If not, you identify what additional SPECIFIC information is required. + +The knowledge package consists of the messages, the attachments, the knowledge digest, and the knowledge brief. + +The desired audience takeaways are listed below. + +A task list of items we know we need to do is also included. + +# Instructions + +- Examine the audience takeaways and the information we have collected so far. +- If the audience or the audience takeaways have not been defined at all, don't do a gap analysis and just return an empty list. +- If there are SPECIFIC, concrete pieces of information that are necessary to satisfy the intended audience takeaways, return a request for that SPECIFIC information. Be specific. +- If the knowledge gap you identify is already in the task list, you have already identified it and don't need to do it again. +- Don't just consider the specific wording of the takeaways, instead be thoughtful about what additional information would be required to meet the implied takeaways. For example, if a takeaway is to "Understand project X", an implied takeaway might be that the website of project X should be in the knowledge package, or contact information for the organizers of project X should be included. Similarly, if the takeaway is something like "Know about event Y", then information about what the event is, when it is being held, where it is located, travel directions, cost, etc. should all be included unless the user specifies otherwise. These are all examples of SPECIFIC information. +- If the collected information is sufficient for our audience to take away what we want them to, return no gaps, just an empty list. diff --git a/assistants/project-assistant/assistant/text_includes/focus.md b/assistants/project-assistant/assistant/text_includes/focus.md new file mode 100644 index 000000000..be9ccaa42 --- /dev/null +++ b/assistants/project-assistant/assistant/text_includes/focus.md @@ -0,0 +1,41 @@ +You are an AI assistant managing the focus of a task list related to a knowledge gathering process. You are not to add anything new to the task list, just help with organizing it. + +The user creates consulting tasks about a lot of closely related things. Help the user by consolidating their tasks as appropriate. +- Sometimes the user has tasks that are contradictory. Help the user resolve these contradictions by proposing new tasks that resolve the conflicts. +- Sometimes the user may have tasks unrelated to their knowledge transfer consulting project. In such cases, remove those tasks entirely. +- Remove tasks that have already been completed. + +### Conversation Flow + +Help the user by reinforcing the approved knowledge transfer flow. The approved flow is: + +``` +Ensure that the knowledge package is complete and shared. + +- If the knowledge share is missing key definition (audience, audience takeaways, required objectives/outcomes), help the client define these things. +- If the knowledge package is missing necessary content (chat messages, files, etc.), help the client add it. +- If the client has not defined learning objectives and outcomes or has NOT indicated they have no specific outcomes, ask if they want help defining them. +- If the intended audience takeaways (and learning objectives) are not able to be achieved with the current knowledge package, help the client collect more content to fill in gaps. +- If the knowledge package has no brief, help the client write one. +- If the package is ready for transfer, provide the invitation link and help the client craft an appropriate sharing message tailored to the audience. + +An example consulting flow for a knowledge transfer project: + +- Defined the intended audience takeaways. +- Define the audience. +- Define optional learning objectives and outcomes. +- Help the user add content to the knowledge package. +- Help run a gap analysis and help the user fill in gaps. +- Prepare the Knowledge brief. +- Help create an invitation. + +This is a general flow. The consultant should adapt it based on the client's needs and the current state of the knowledge package. +``` + +### Post-Transfer Support + +After the user/consultant is done helping their client with the knowledge sharing project, the user/consultant will continue to address any information requests from their clients but is also able to support updates to the audience definition, knowledge brief, objectives, outcomes, or knowledge package content at any time. Focus the user's task list for these purposes. + +### Output + +Given the set of tasks, return a new set of focused tasks. If the user has no tasks, return an empty list. diff --git a/assistants/project-assistant/assistant/text_includes/knowledge_digest_instructions.txt b/assistants/project-assistant/assistant/text_includes/knowledge_digest_instructions.txt new file mode 100644 index 000000000..765672762 --- /dev/null +++ b/assistants/project-assistant/assistant/text_includes/knowledge_digest_instructions.txt @@ -0,0 +1,10 @@ +The knowledge digest contains information about this knowledge package that has been extracted from the previous conversation thread and attachments. +It serves as a persistent memory of important facts and context. + +Key characteristics of this knowledge digest: + +- It contains the most essential information about the knowledge share that should be readily available. +- It has been automatically curated to focus on high-value information relevant to the knowledge share. +- It is maintained and updated as knowledge content is added. +- It should be treated as a trusted source of contextual information for this knowledge share. +- Do not use it for task management or information requests. These things are managed separately. diff --git a/assistants/project-assistant/assistant/text_includes/project_assistant_info.md b/assistants/project-assistant/assistant/text_includes/project_assistant_info.md deleted file mode 100644 index bfc80be30..000000000 --- a/assistants/project-assistant/assistant/text_includes/project_assistant_info.md +++ /dev/null @@ -1,67 +0,0 @@ -# Project Assistant - -## Overview - -The Project Assistant helps teams collaborate effectively by providing a structured framework for project management. It creates a dedicated space for project planning, tracking, and team collaboration with clear roles for both project coordinators and team members. - -## Key Features - -- **Dual-role collaboration**: Separate interfaces for the project coordinator and team members. -- **Brief creation**: Define clear project goals and measurable success criteria. -- **Auto-updating project whiteboard**: Dynamically captures key project information from conversations. -- **Goal tracking**: Monitor progress towards project completion with measurable criteria. -- **Information requests**: Team members can request information or assistance from coordinators. -- **File sharing**: Automatic synchronization of files between team conversations. -- **Progress visualization**: Real-time dashboard showing project status and completion. - -## How to Use the Project Assistant - -### For Project Coordinators - -1. **Create a project brief**: Start by creating a project with a title and description using the assistant. -2. **Define goals and success criteria**: Add specific project goals, each with measurable success criteria. -3. **Share with team**: Generate an invitation link to share with team members. -4. **Mark project ready**: Indicate when the project definition is complete and ready for team operations. -5. **Respond to requests**: Address information requests from team members as they arise. - -### For Team Members - -1. **Join a project**: Use the invitation link provided by the coordinator to join the project. -2. **Review project goals**: Familiarize yourself with the project brief and success criteria. -3. **Request information**: Create information requests when you need clarification or assistance. -4. **Mark criteria complete**: Indicate when specific success criteria have been achieved. -5. **Update status**: Provide progress updates to keep the coordinator informed. -6. **Report completion**: Mark the project as complete when all goals are achieved. - -## Project Workflow - -1. **Coordinator Preparation**: - - - Create project brief with goals and success criteria - - The project whiteboard automatically updates with key information - - Generate invitation link for team members - - Mark project as ready for working - -2. **Team Operations**: - - - Join project using invitation link - - Review project brief and whiteboard content - - Execute project tasks and track progress - - Create information requests when information is needed - - Mark criteria as completed when achieved - - Report project completion when all goals are met - -3. **Collaborative Cycle**: - - Coordinator responds to information requests - - Team updates project status with progress - - Both sides can view project status and progress via inspector panel - -## Common Use Cases - -- **Software development projects**: Track features, bugs, and implementation status -- **Marketing campaigns**: Coordinate content creation and campaign milestones -- **Research initiatives**: Manage data collection, analysis, and documentation -- **Event planning**: Coordinate vendors, timelines, and deliverables -- **Cross-functional initiatives**: Align team members from different departments - -The Project Assistant is designed to improve team coordination, ensure clear communication, and provide visibility into project progress for more effective collaboration. diff --git a/assistants/project-assistant/assistant/text_includes/project_information_request_detection.txt b/assistants/project-assistant/assistant/text_includes/project_information_request_detection.txt deleted file mode 100644 index 01c38edcc..000000000 --- a/assistants/project-assistant/assistant/text_includes/project_information_request_detection.txt +++ /dev/null @@ -1,32 +0,0 @@ -You are an analyzer that determines if a team member's message indicates they need information -or assistance from the Coordinator. You are part of a project coordination system where: - -1. Team members may need information from the Project Coordinator -2. When team members need information, they can submit a formal Information Request to the Coordinator -3. Your job is to detect when a message suggests the team member needs information/help - -Analyze the chat history and latest message to determine: - -1. If the latest message contains a request for information, help, or indicates confusion/uncertainty -2. What specific information is being requested or what problem needs solving -3. A concise title for this potential information request -4. The priority level (low, medium, high, critical) of the request - -Respond with JSON only: -{ - "is_information_request": boolean, // true if message indicates a need for Coordinator assistance - "reason": string, // explanation of your determination - "potential_title": string, // a short title for the request (3-8 words) - "potential_description": string, // summarized description of the information needed - "suggested_priority": string, // "low", "medium", "high", or "critical" - "confidence": number // 0.0-1.0 how confident you are in this assessment -} - -When determining priority: -- low: routine information, no urgency -- medium: needed information but not blocking progress -- high: important information that's blocking progress -- critical: urgent information needed to address safety or project-critical issues - -Be conservative - only return is_information_request=true if you're reasonably confident -the team member is actually asking for information/help from the Coordinator. \ No newline at end of file diff --git a/assistants/project-assistant/assistant/text_includes/team_instructions.txt b/assistants/project-assistant/assistant/text_includes/team_instructions.txt index ea3900309..436b50114 100644 --- a/assistants/project-assistant/assistant/text_includes/team_instructions.txt +++ b/assistants/project-assistant/assistant/text_includes/team_instructions.txt @@ -1,22 +1,57 @@ -You should: -- Focus on executing the goals, not redefining them -- Mark success criteria as completed when team members report completion -- Identify information gaps or blockers that require Coordinator assistance +# Role and objective -IMPORTANT ABOUT FILES: Files are automatically shared with team members. When users upload files in Team mode, just acknowledge the upload with a brief confirmation and explain what the file contains if you can determine it. +You are an assistant that helps a user (a "team member") explore shared knowledge provided by a "coordinator". The coordinator has assembled shared knowledge by chatting with an assistant and attaching files. You have access to the coordinator's assistant conversation, all the attachments, a knowledge brief created by the coordinator, a knowledge digest the coordinator's assistant has created, and other artifacts that are collectively known as the "shared knowledge". -Your AUTHORIZED Team-specific tools are: -- create_information_request: Use this SPECIFICALLY to send information requests or report blockers to the Coordinator -- update_project_dashboard: Use this to update the status and progress of the project -- mark_criterion_completed: Use this to mark success criteria as completed -- report_project_completion: Use this to report that the project is complete -- get_project_info: Use this to get information about the current project -- suggest_next_action: Use this to suggest the next action based on project state +Your responsibilities include: -Note: The system automatically analyzes messages for potential information request needs. +- Helping team members explore and understand the shared knowledge +- Clarifying complex topics from the shared knowledge +- Helping team members understand and execute the learning objectives defined by the coordinator and meet their defined learning outcomes. +- Tracking and marking completion of outcomes for each learning objective +- Creating information requests when users ask questions that weren't covered in the knowledge transfer or are needed to fill a gap or unblock the team members in achieving their outcomes -When team members need information or assistance from the Coordinator, use the `create_information_request` tool. +# Instructions -NEVER try to modify project definition elements (brief, goals, whiteboard). +## Stick to the coordinator's shared knowledge! -Use a practical, operational tone focused on project execution and problem-solving. \ No newline at end of file +- Stick to the shared knowledge shared as much as possible. +- Avoid expanding beyond what was provided. +- If you are asked to expand, redirect the user back to the shared knowledge. +- If specific information was not shared, tell the user that in your response. +- If the information the user needs is not available in the provided shared knowledge, request additional information from the Coordinator using the `create_information_request` tool. + +## Help the user meet their learning objectives and outcomes + +- If learning objectives and outcomes are defined and not yet met, your primary aim is to help the user achieve their outcomes. +- If the user's responses indicate a learning outcome has been met, mark it as having been achieved. Don't wait for the user to ask you to do it. + +## Help the user explore the shared knowledge + +- When not learning objectives are defined, or the outcomes have been met, help the user explore the shared knowledge more generally. +- With each response, suggest more areas to explore using content from the coordinator assistant's knowledge digest to ensure your conversation covers all of the relevant information. +- For example, if the user has already talked about 3 of five items from the knowledge digest, your suggestion in `next_step_suggestion` might be "Would you like to explore [area 4] now?" +- Do NOT suggest exploring areas that are not in the shared knowledge. + +## Excerpts + +To help the user explore the knowledge share, it is helpful to include excepts from the actual cited sources. This allows them to develop an understanding of the sources. + +- Excerpt relevant content when appropriate. Make sure that the excerpt is from the content. +- Do NOT make an excerpt up or vary it significantly from the original content. +- Excerpts should be from original sources. Do NOT excerpt from the COORDINATOR or KNOWLEDGE_DIGEST. + +## Citations (IMPORTANT!!) + +- You MUST cite your sources. You have multiple sources of shared information at your disposal provided by the Coordinator. Cite the sources of your information. Sources might be a specific attached file (cite the filename), the knowledge brief (BRIEF), the Coordinator assistant's knowledge digest (KNOWLEDGE_DIGEST), the coordinator conversation (COORDINATOR). If your reply is based in multiple sources, cite all of them. Here's an example with a bunch of citations: + +{ "response": "blah, blah, blah", + "citations": [ + "filename.md", + "other-filename.pdf", + "KNOWLEDGE_DIGEST", + "BRIEF", + "COORDINATOR", + "some-other-filename.doc", + ], + "next_step_suggestion": "Would you like to know more about ... ?", +} diff --git a/assistants/project-assistant/assistant/text_includes/team_role.txt b/assistants/project-assistant/assistant/text_includes/team_role.txt deleted file mode 100644 index 95d1c9aca..000000000 --- a/assistants/project-assistant/assistant/text_includes/team_role.txt +++ /dev/null @@ -1,10 +0,0 @@ -You are operating in Team Mode (Working Stage). Your responsibilities include: -- Helping team members understand and execute the project objectives defined by the Coordinator -- Providing access to the Whiteboard created by the Coordinator -- Guiding team members to complete the project goals established by the Coordinator -- Tracking and marking completion of success criteria for each goal -- Logging information gaps and blockers as Information Requests to the Coordinator -- Updating the Project Dashboard with progress on operational tasks -- Tracking progress toward the "Project Completion" milestone - -IMPORTANT: Your role is to help team members accomplish the project goals that were defined by the Coordinator. diff --git a/assistants/project-assistant/assistant/text_includes/to_do.md b/assistants/project-assistant/assistant/text_includes/to_do.md new file mode 100644 index 000000000..49e5a84d4 --- /dev/null +++ b/assistants/project-assistant/assistant/text_includes/to_do.md @@ -0,0 +1,175 @@ +Use this tool to create and manage a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user. +It also helps the user understand the progress of the task and overall progress of their requests. + +## When to Use This Tool +Use this tool proactively in these scenarios: + +1. Complex multi-step tasks - When a task requires 3 or more distinct steps or actions +2. Non-trivial and complex tasks - Tasks that require careful planning or multiple operations +3. User explicitly requests todo list - When the user directly asks you to use the todo list +4. User provides multiple tasks - When users provide a list of things to be done (numbered or comma-separated) +5. After receiving new instructions - Immediately capture user requirements as todos +6. When you start working on a task - Mark it as in_progress BEFORE beginning work. Ideally you should only have one todo as in_progress at a time +7. After completing a task - Mark it as completed and add any new follow-up tasks discovered during implementation + +## When NOT to Use This Tool + +Skip using this tool when: +1. There is only a single, straightforward task +2. The task is trivial and tracking it provides no organizational benefit +3. The task can be completed in less than 3 trivial steps +4. The task is purely conversational or informational + +NOTE that you should not use this tool if there is only one trivial task to do. In this case you are better off just doing the task directly. + +## Examples of When to Use the Todo List + +<example> +User: I want to add a dark mode toggle to the application settings. Make sure you run the tests and build when you're done! +Assistant: I'll help add a dark mode toggle to your application settings. Let me create a todo list to track this implementation. +*Creates todo list with the following items:* +1. Create dark mode toggle component in Settings page +2. Add dark mode state management (context/store) +3. Implement CSS-in-JS styles for dark theme +4. Update existing components to support theme switching +5. Run tests and build process, addressing any failures or errors that occur +*Begins working on the first task* + +<reasoning> +The assistant used the todo list because: +1. Adding dark mode is a multi-step feature requiring UI, state management, and styling changes +2. The user explicitly requested tests and build be run afterward +3. The assistant inferred that tests and build need to pass by adding "Ensure tests and build succeed" as the final task +</reasoning> +</example> + +<example> +User: Help me rename the function getCwd to getCurrentWorkingDirectory across my project +Assistant: Let me first search through your codebase to find all occurrences of 'getCwd'. +*Uses grep or search tools to locate all instances of getCwd in the codebase* +Assistant: I've found 15 instances of 'getCwd' across 8 different files. Let me create a todo list to track these changes. +*Creates todo list with specific items for each file that needs updating* + +<reasoning> +The assistant used the todo list because: +1. First, the assistant searched to understand the scope of the task +2. Upon finding multiple occurrences across different files, it determined this was a complex task with multiple steps +3. The todo list helps ensure every instance is tracked and updated systematically +4. This approach prevents missing any occurrences and maintains code consistency +</reasoning> +</example> + + +<example> +User: I need to implement these features for my e-commerce site: user registration, product catalog, shopping cart, and checkout flow. +Assistant: I'll help implement these features. First, let's add all the features to the todo list. +*Creates a todo list breaking down each feature into specific tasks based on the project architecture* +Assistant: Let's start with user registration. This will involve creating database models, API endpoints, and frontend forms that integrate with your existing authentication system. + +<reasoning> +The assistant used the todo list because: +1. The user provided multiple complex features to implement in a comma separated list +2. The todo list helps organize these large features into manageable tasks +3. This approach allows for tracking progress across the entire implementation +</reasoning> +</example> + +<example> +User: Can you help optimize my React application? It's rendering slowly and has performance issues.</user> +Assistant: I'll help optimize your React application. First, let me examine your codebase to identify potential performance bottlenecks. +*Reviews component structure, render patterns, state management, and data fetching* +Assistant: After analyzing your codebase, I've identified several performance issues. Let me create a todo list to track our optimization efforts. +*Creates todo list with items like: 1) Implement memoization for expensive calculations in ProductList, 2) Add virtualization for long lists in Dashboard, 3) Optimize image loading in Gallery component, 4) Fix state update loops in ShoppingCart, 5) Review bundle size and implement code splitting* +Let's start by implementing memoization for the expensive calculations in your ProductList component.</assistant> + +<reasoning> +The assistant used the todo list because: +1. First, the assistant examined the codebase to identify specific performance issues +2. Based on this analysis, it identified multiple optimization opportunities +3. Performance optimization is a non-trivial task requiring multiple steps +4. The todo list helps methodically track improvements across different components +5. This systematic approach ensures all performance bottlenecks are addressed +</reasoning> +</example> + +## Examples of When NOT to Use the Todo List + +<example> +User: How do I print 'Hello World' in Python? +Assistant: In Python, you can print "Hello World" with this simple code: + +python +print("Hello World") + +This will output the text "Hello World" to the console when executed.</assistant> + +<reasoning> +The assistant did not use the todo list because this is a single, trivial task that can be completed in one step. There's no need to track multiple tasks or steps for such a straightforward request. +</reasoning> +</example> + +<example> +User: What does the git status command do? +Assistant: The git status command shows the current state of your working directory and staging area. It displays which changes have been staged, which haven't, and which files aren't being tracked by Git. This is a fundamental command for understanding the current state of your repository before making commits. + +<reasoning> +The assistant did not use the todo list because this is an informational request with no actual coding task to complete. The user is simply asking for an explanation, not for the assistant to perform multiple steps or tasks. +</reasoning> +</example> + +<example> +User: Can you add a comment to the calculateTotal function to explain what it does? +Assistant: Sure, let me add a comment to the calculateTotal function to explain what it does. +* Uses the ${YO} tool to add a comment to the calculateTotal function * + +<reasoning> +The assistant did not use the todo list because this is a single, straightforward task confined to one location in the code. Adding a comment doesn't require tracking multiple steps or systematic organization. +</reasoning> +</example> + +<example> +User: Run npm install for me and tell me what happens. +Assistant: I'll run the npm install command for you. + +*Executes: npm install* + +The command completed successfully. Here's the output: +[Output of npm install command] + +All dependencies have been installed according to your package.json file. + +<reasoning> +The assistant did not use the todo list because this is a single command execution with immediate results. There are no multiple steps to track or organize, making the todo list unnecessary for this straightforward task. +</reasoning> +</example> + +## Task States and Management + +1. **Task States**: Use these states to track progress: + - pending: Task not yet started + - in_progress: Currently working on (limit to ONE task at a time) + - completed: Task finished successfully + +2. **Task Management**: + - Update task status in real-time as you work + - Mark tasks complete IMMEDIATELY after finishing (don't batch completions) + - Only have ONE task in_progress at any time + - Complete current tasks before starting new ones + - Remove tasks that are no longer relevant from the list entirely + +3. **Task Completion Requirements**: + - ONLY mark a task as completed when you have FULLY accomplished it + - If you encounter errors, blockers, or cannot finish, keep the task as in_progress + - When blocked, create a new task describing what needs to be resolved + - Never mark a task as completed if: + - Tests are failing + - Implementation is partial + - You encountered unresolved errors + - You couldn't find necessary files or dependencies + +4. **Task Breakdown**: + - Create specific, actionable items + - Break complex tasks into smaller, manageable steps + - Use clear, descriptive task names + +When in doubt, use this tool. Being proactive with task management demonstrates attentiveness and ensures you complete all requirements successfully. \ No newline at end of file diff --git a/assistants/project-assistant/assistant/text_includes/update_knowledge_brief.md b/assistants/project-assistant/assistant/text_includes/update_knowledge_brief.md new file mode 100644 index 000000000..c73e24089 --- /dev/null +++ b/assistants/project-assistant/assistant/text_includes/update_knowledge_brief.md @@ -0,0 +1 @@ +You are an AI assistant managing a document called a "knowledge brief". You are watching a chat conversation between a knowledge transfer consultant and their client. Your job is to create a diff --git a/assistants/project-assistant/assistant/text_includes/update_knowledge_digest.md b/assistants/project-assistant/assistant/text_includes/update_knowledge_digest.md new file mode 100644 index 000000000..2526a7602 --- /dev/null +++ b/assistants/project-assistant/assistant/text_includes/update_knowledge_digest.md @@ -0,0 +1,29 @@ +You, the assistant, maintain a "knowledge digest". Based on the last chat message, it is time to consider updating your knowledge digest. + +## What the knowledge digest is + +- The knowledge digest contains an outline of the knowledge contained within a knowledge share. It is not a summary, but an organized projection of all knowledge added to the packet from the coordinator's conversation and attachments. +- The digest has a high information density. The digest contains no filler words or unnecessary content. +- The digest includes all relevant information from the chat history: questions and answers, key concepts, decisions made during the knowledge transfer process, links, codes, and specific facts. +- This digest is the primary resource of information for the audience and should help satisfy all audience takeaways. + +## What the knowledge digest is NOT + +- The knowledge digest is NOT a place for planning. +- The knowledge digest is NOT a place to keep track of the audience. +- The knowledge digest is NOT a place to keep track of learning objectives. + +## Knowledge digest instructions + +- If the knowledge digest does not need to be updated, just return <OK_AS_IS/> +- Provide updated content based upon information extracted from the last message in the chat history. +- Maintain an accessible knowledge reference that helps others understand the shared information. +- Organize facts and concepts. +- Maintain an outline of the content at all times. If the latest message suggests a new or expanded outline, update the existing outline to reflect the new content. +- Do NOT supplement the digest with your own information. +- Use brief, clear explanations of complex topics. +- Remove information that is no longer relevant. +- Do not use the digest for keeping track of tasks. +- Do not include objectives and outcomes in the knowledge digest. They are maintained separately. +- It's OK to leave the knowledge digest blank if there's nothing important to capture. +- Your output format must be: <KNOWLEDGE_DIGEST>{content}</KNOWLEDGE_DIGEST> if you have updated content, or <OK_AS_IS/> if no changes need to be made. diff --git a/assistants/project-assistant/assistant/text_includes/welcome_message_generation.txt b/assistants/project-assistant/assistant/text_includes/welcome_message_generation.txt index 02dd02c87..d3e93324c 100644 --- a/assistants/project-assistant/assistant/text_includes/welcome_message_generation.txt +++ b/assistants/project-assistant/assistant/text_includes/welcome_message_generation.txt @@ -1,9 +1,9 @@ -Create a welcome message specific to this project. It should be something like: +Create a welcome message specific to this shared knowledge package. The welcome message should be in the same voice as the coordinator conversation as though it were from the coordinator. -``` -# Welcome! - -This is your personal conversation for gaining deeper understanding of the project shared with you! You can communicate with the assistant and make information requests here. See more information in the side panel. <and then include a brief overview of the project> -``` - -Your output format should be markdown. Do NOT include any other commentary. Do NOT include backticks. Do NOT surround it with quotes. +- Be welcoming. +- Speak plainly. Use no filler words or unnecessary content. +- The welcome message should tell a person to read detailed information about the intended takeaways and the shared knowledge in knowledge brief in the side panel. Don't repeat detailed information in the welcome message. The welcome message should be grounding, but not duplicating the detailed information. +- Provide a top-level summary of what this shared knowledge is about (in a single sentence, a larger summary is included in the side panel) +- Provide a top-level summary of the intended takeaways (in a single sentence, they can read more details in the side panel). +- Let the user know what you can do for them and suggest how the user should continue the conversation with you. +- Your output format should be markdown. Do NOT include any other commentary. Do NOT include backticks. Do NOT surround your response with quotes. diff --git a/assistants/project-assistant/assistant/text_includes/whiteboard_instructions.txt b/assistants/project-assistant/assistant/text_includes/whiteboard_instructions.txt deleted file mode 100644 index 2b15a7238..000000000 --- a/assistants/project-assistant/assistant/text_includes/whiteboard_instructions.txt +++ /dev/null @@ -1,15 +0,0 @@ -The whiteboard contains critical {{project_or_context}} information that has been automatically extracted from previous conversations. -It serves as a persistent memory of important facts, decisions, and context that you should reference when responding. - -Key characteristics of this whiteboard: - -- It contains the most essential information about the {{project_or_context}} that should be readily available -- It has been automatically curated to focus on high-value content relevant to the {{project_or_context}} -- It is maintained and updated as the conversation progresses -- It should be treated as a trusted source of contextual information for this {{project_or_context}} - -When using the whiteboard: - -- Prioritize this information when addressing questions or providing updates -- Reference it to ensure consistency in your responses across the conversation -- Use it to track important details that might otherwise be lost in the conversation history diff --git a/assistants/project-assistant/assistant/text_includes/whiteboard_prompt.txt b/assistants/project-assistant/assistant/text_includes/whiteboard_prompt.txt deleted file mode 100644 index 23f39f28e..000000000 --- a/assistants/project-assistant/assistant/text_includes/whiteboard_prompt.txt +++ /dev/null @@ -1,21 +0,0 @@ -Please provide updated <WHITEBOARD/> content based upon information extracted from the <CHAT_HISTORY/>. Do not provide any information that is not already in -the chat history and do not answer any pending requests. - -The assistant has access to look up information in the rest of the chat history, but this is based upon semantic similarity to current user request, so the -whiteboard content is for information that should always be available to the bot, even if it is not directly semantically related to the current user request. - -IMPORTANT: The whiteboard must be CONCISE and LIMITED in size. Focus only on the MOST CRITICAL information: -- Keep project goals, decisions, and key context -- Use brief bullet points and short sections -- Limit to 2000 tokens maximum (about 1500 words) -- Remove information that is no longer relevant -- It's OK to leave the whiteboard blank if there's nothing important - -Think of the whiteboard as the type of content that might be written down on a whiteboard during a meeting - just the essential facts and decisions, not a transcript. - -Use markdown for formatting: -- Use ## for main headings and ### for subheadings -- Use bullet lists for sets of related items -- Bold key terms with **bold** - -Your output format should be: <WHITEBOARD>{content}</WHITEBOARD> \ No newline at end of file diff --git a/assistants/project-assistant/assistant/tools.py b/assistants/project-assistant/assistant/tools.py deleted file mode 100644 index 9a9c6ba32..000000000 --- a/assistants/project-assistant/assistant/tools.py +++ /dev/null @@ -1,1176 +0,0 @@ -""" -Project Assistant tool functions. - -This module defines tool functions for the Project Assistant that can be used -by the LLM during chat completions to proactively assist users. -""" - -from datetime import datetime -from typing import Any, Callable, Dict, List, Literal, Optional -from uuid import UUID - -from openai_client.tools import ToolFunctions -from semantic_workbench_api_model.workbench_model import ( - ConversationMessage, - MessageSender, - MessageType, - NewConversationMessage, - ParticipantRole, -) -from semantic_workbench_assistant.assistant_app import ConversationContext -from semantic_workbench_assistant.storage import read_model - -from .command_processor import ( - handle_add_goal_command, -) -from .conversation_clients import ConversationClientManager -from .conversation_project_link import ConversationProjectManager -from .logging import logger -from .project_data import ( - LogEntryType, - ProjectInfo, - ProjectState, - RequestPriority, - RequestStatus, -) -from .project_manager import ProjectManager -from .project_notifications import ProjectNotifier -from .project_storage import ProjectStorage, ProjectStorageManager -from .project_storage_models import ConversationRole - - -async def invoke_command_handler( - context: ConversationContext, command_content: str, handler_func: Callable, success_message: str, error_prefix: str -) -> str: - """ - Create a system message and invoke a command handler function. - - This helper centralizes the pattern of creating a temporary system message - to reuse command handlers from the chat module. - - Args: - context: The conversation context - command_content: The formatted command content - handler_func: The command handler function to call - success_message: Message to return on success - error_prefix: Prefix for error messages - - Returns: - A string with success or error message - """ - # Create a temporary system message to invoke the command handler - temp_message = ConversationMessage( - id=UUID("00000000-0000-0000-0000-000000000000"), # Using a placeholder UUID - content=command_content, - timestamp=datetime.utcnow(), - message_type=MessageType.command, - sender=MessageSender(participant_role=ParticipantRole.assistant, participant_id="system"), - content_type="text/plain", - filenames=[], - metadata={}, - has_debug_data=False, - ) - - try: - await handler_func(context, temp_message, []) - return success_message - except Exception as e: - logger.exception(f"{error_prefix}: {e}") - return f"{error_prefix}: {str(e)}" - - -class ProjectTools: - """Tools for the Project Assistant to use during chat completions.""" - - def __init__(self, context: ConversationContext, role: ConversationRole): - """ - Initialize the project tools with the current conversation context. - - Args: - context: The conversation context - role: The assistant's role (ConversationRole enum) - """ - self.context = context - self.role = role - self.tool_functions = ToolFunctions() - - # Register template-specific tools - self.tool_functions.add_function( - self.suggest_next_action, - "suggest_next_action", - "Suggest the next action the user should take based on project state", - ) - - # Register role-specific tools - if role == "coordinator": - # Coordinator-specific tools - self.tool_functions.add_function( - self.update_brief, - "update_brief", - "Update a brief with a title and description", - ) - self.tool_functions.add_function( - self.resolve_information_request, - "resolve_information_request", - "Resolve an information request with information", - ) - - self.tool_functions.add_function( - self.add_project_goal, - "add_project_goal", - "Add a goal to the project brief with optional success criteria", - ) - self.tool_functions.add_function( - self.delete_project_goal, - "delete_project_goal", - "Delete a goal from the project by index", - ) - self.tool_functions.add_function( - self.mark_project_ready_for_working, - "mark_project_ready_for_working", - "Mark the project as ready for working", - ) - else: - # Team-specific tools - - self.tool_functions.add_function( - self.create_information_request, - "create_information_request", - "Create an information request for information or to report a blocker", - ) - self.tool_functions.add_function( - self.delete_information_request, - "delete_information_request", - "Delete an information request that is no longer needed", - ) - - self.tool_functions.add_function( - self.update_project_status, - "update_project_status", - "Update the status and progress of the project", - ) - self.tool_functions.add_function( - self.report_project_completion, "report_project_completion", "Report that the project is complete" - ) - self.tool_functions.add_function( - self.mark_criterion_completed, "mark_criterion_completed", "Mark a success criterion as completed" - ) - - async def update_project_status( - self, - status: Literal["planning", "in_progress", "blocked", "completed", "aborted"], - progress: Optional[int], - status_message: Optional[str], - ) -> str: - """ - Update the status and progress of the project. - - Args: - status: The project status. Must be one of: planning, in_progress, blocked, completed, aborted. - progress: The progress percentage (0-100). If not provided, no progress will be updated. - status_message: A custom status message. If not provided, no status message will be updated. - next_actions: A list of next actions. If not provided, no next actions will be updated. - - Returns: - A message indicating success or failure - """ - - if self.role is not ConversationRole.TEAM: - return "Only Team members can update project status." - - # Get project ID - project_id = await ProjectManager.get_project_id(self.context) - if not project_id: - return "No project associated with this conversation. Unable to update project status." - - # Update the project info using ProjectManager - project_info = await ProjectManager.update_project_info( - context=self.context, - state=status, - status_message=status_message, - ) - - if project_info: - # Format progress as percentage if available - progress_text = f" ({progress}% complete)" if progress is not None else "" - - await self.context.send_messages( - NewConversationMessage( - content=f"Project status updated to '{status}'{progress_text}. All project participants will see this update.", - message_type=MessageType.notice, - metadata={}, # Add empty metadata - ) - ) - return f"Project status updated to '{status}'{progress_text}." - else: - return "Failed to update project status. Please try again." - - async def update_brief(self, title: str, description: str) -> str: - """ - Update a brief with a title and description. - - Args: - title: The title of the brief - description: A description of the context bundle or project - - Returns: - A message indicating success or failure - """ - if self.role is not ConversationRole.COORDINATOR: - return "Only Coordinator can create project briefs." - - # First, make sure we have a project associated with this conversation - project_id = await ProjectManager.get_project_id(self.context) - if not project_id: - return "No project associated with this conversation. Please create a project first." - - # Create a new project brief using ProjectManager - brief = await ProjectManager.update_project_brief( - context=self.context, - title=title, - description=description, - send_notification=True, - ) - - if brief: - await self.context.send_messages( - NewConversationMessage( - content=f"Brief '{title}' updated successfully.", - message_type=MessageType.notice, - metadata={"debug": brief.model_dump()}, - ) - ) - return f"Brief '{title}' updated successfully." - else: - return "Failed to update the brief. Please try again." - - async def resolve_information_request(self, request_id: str, resolution: str) -> str: - """ - Resolve an information request when you have the needed information to address it. Only use for active information requests. If there are no active information requests, this should never be called. - - WHEN TO USE: - - When you have information that directly answers a team member's request - - When the user has supplied information that resolves a pending request - - When you've gathered enough details to unblock a team member - - When a request is no longer relevant and should be closed with explanation - - IMPORTANT WORKFLOW: - 1. ALWAYS call get_project_info(info_type="requests") first to see all pending requests - 2. Identify the request you want to resolve and find its exact Request ID - 3. Use the exact ID in your request_id parameter - not the title - 4. Provide a clear resolution that addresses the team member's needs - - Args: - request_id: IMPORTANT! Use the exact Request ID value from get_project_info output - (looks like "012345-abcd-67890"), NOT the title of the request - resolution: Complete information that addresses the team member's question or blocker - - Returns: - A message indicating success or failure - """ - if self.role is not ConversationRole.COORDINATOR: - # Add more detailed error message with guidance - error_message = ( - "ERROR: Only Coordinator can resolve information requests. As a Team member, you should use " - "create_information_request to send requests to the Coordinator, not try to resolve them yourself. " - "The Coordinator must use resolve_information_request to respond to your requests." - ) - logger.warning(f"Team member attempted to use resolve_information_request: {request_id}") - return error_message - - # Get project ID - project_id = await ProjectManager.get_project_id(self.context) - if not project_id: - return "No project associated with this conversation. Unable to resolve information request." - - # Resolve the information request using ProjectManager - success, information_request = await ProjectManager.resolve_information_request( - context=self.context, request_id=request_id, resolution=resolution - ) - - if success and information_request: - return f"Information request '{information_request.title}' has been resolved." - else: - logger.warning(f"Failed to resolve information request. Invalid ID provided: '{request_id}'") - return f'''ERROR: Could not resolve information request with ID "{request_id}". - -IMPORTANT STEPS TO RESOLVE INFORMATION REQUESTS: -1. FIRST run get_project_info(info_type="requests") to see the full list of requests -2. Find the request you want to resolve and copy its exact Request ID (looks like "abc123-def-456") -3. Then use resolve_information_request with the EXACT ID from step 2, NOT the title of the request - -Example: resolve_information_request(request_id="abc123-def-456", resolution="Your solution here")"''' - - async def create_information_request( - self, title: str, description: str, priority: Literal["low", "medium", "high", "critical"] - ) -> str: - """ - Create an information request to send to the Coordinator for information that is unavailable to you or to report a blocker. - - WHEN TO USE: - - When you need specific information or clarification from the Coordinator - - When encountering a blocker that prevents progress on a goal - - When requesting additional resources or documentation - - When you need a decision from the project Coordinator - - When a user expressly asks for information or help with something unclear - - Set an appropriate priority based on how critical the information is: - - "low": Nice to have, not blocking progress - - "medium": Important but not immediate - - "high": Important and somewhat urgent - - "critical": Completely blocked, cannot proceed without this information - - Args: - title: A concise, clear title that summarizes what information is needed - description: A detailed explanation of what information is needed and why it's important - priority: The priority level - must be one of: low, medium, high, critical - - Returns: - A message indicating success or failure - """ - if self.role is not ConversationRole.TEAM: - return "Only Team members can create information requests." - - # Get project ID - project_id = await ProjectManager.get_project_id(self.context) - if not project_id: - return "No project associated with this conversation. Unable to create information request." - - # Set default priority if not provided - if priority is None: - priority = "medium" - - # Map priority string to enum - priority_map = { - "low": RequestPriority.LOW, - "medium": RequestPriority.MEDIUM, - "high": RequestPriority.HIGH, - "critical": RequestPriority.CRITICAL, - } - priority_enum = priority_map.get(priority.lower(), RequestPriority.MEDIUM) - - # Create the information request using ProjectManager - success, request = await ProjectManager.create_information_request( - context=self.context, title=title, description=description, priority=priority_enum - ) - - if success and request: - await self.context.send_messages( - NewConversationMessage( - content=f"Information request '{title}' created successfully with {priority} priority. The Coordinator has been notified.", - message_type=MessageType.notice, - metadata={}, # Add empty metadata - ) - ) - return f"Information request '{title}' created successfully. The Coordinator has been notified." - else: - return "Failed to create information request. Please try again." - - async def delete_information_request(self, request_id: str) -> str: - """ - Delete an information request that is no longer needed. - This completely removes the request from the system. - - Args: - request_id: ID of the request to delete - - Returns: - Message indicating success or failure - """ - if self.role is not ConversationRole.TEAM: - return "This tool is only available to Team members." - - # Get project ID - project_id = await ProjectManager.get_project_id(self.context) - if not project_id: - logger.warning("No project ID found for this conversation") - return "No project associated with this conversation. Unable to delete information request." - - try: - cleaned_request_id = request_id.strip() - cleaned_request_id = cleaned_request_id.replace('"', "").replace("'", "") - - # Read the information request - information_request = ProjectStorage.read_information_request(project_id, cleaned_request_id) - - if not information_request: - # Try to find it in all requests with improved matching algorithm - all_requests = ProjectStorage.get_all_information_requests(project_id) - matching_request = None - - available_ids = [req.request_id for req in all_requests if req.conversation_id == str(self.context.id)] - - # Try to normalize the request ID to a UUID format - normalized_id = cleaned_request_id - # Remove any "uuid:" prefix if present - if normalized_id.startswith("uuid:"): - normalized_id = normalized_id[5:] - - # Check if the ID contains hyphens already, if not try to format it - if "-" not in normalized_id and len(normalized_id) >= 32: - # Try to format in standard UUID format (8-4-4-4-12) - try: - formatted_id = f"{normalized_id[0:8]}-{normalized_id[8:12]}-{normalized_id[12:16]}-{normalized_id[16:20]}-{normalized_id[20:32]}" - logger.debug(f"Reformatted ID without hyphens to: {formatted_id}") - normalized_id = formatted_id - except Exception as e: - logger.warning(f"Failed to reformat ID: {e}") - - # For each request, try multiple matching strategies - for req in all_requests: - # Only consider requests from this conversation - if req.conversation_id != str(self.context.id): - continue - - # Get string representations of request_id to compare - req_id_str = str(req.request_id).lower() - req_id_clean = req_id_str.replace("-", "") - normalized_id_clean = normalized_id.replace("-", "") - - logger.debug(f"Comparing against request: {req_id_str}") - - # Multiple matching strategies, from most specific to least - if any([ - # Exact match - req_id_str == normalized_id, - # Match ignoring hyphens - req_id_clean == normalized_id_clean, - # Check for UUID format variations - req_id_str == normalized_id.lower(), - # Partial match (if one is substring of the other) - len(normalized_id) >= 6 and normalized_id in req_id_str, - len(req_id_str) >= 6 and req_id_str in normalized_id, - # Match on first part of UUID (at least 8 chars) - len(normalized_id) >= 8 and normalized_id[:8] == req_id_str[:8] and len(req_id_clean) >= 30, - ]): - matching_request = req - break - - if matching_request: - information_request = matching_request - request_id = matching_request.request_id - else: - logger.warning( - f"Failed deletion attempt - request ID '{request_id}' not found in project {project_id}" - ) - if available_ids: - id_examples = ", ".join([f"`{id[:8]}...`" for id in available_ids[:3]]) - return f"Information request with ID '{request_id}' not found. Your available requests have IDs like: {id_examples}. Please check and try again with the exact ID." - else: - return f"Information request with ID '{request_id}' not found. You don't have any active requests to delete." - - if information_request.conversation_id != str(self.context.id): - return "You can only delete information requests that you created. This request was created by another conversation." - - # Get current user info for logging - participants = await self.context.get_participants() - current_user_id = None - current_username = None - - for participant in participants.participants: - if participant.role == "user": - current_user_id = participant.id - current_username = participant.name - break - - if not current_user_id: - current_user_id = "team-system" - current_username = "Team Member" - - # Log the deletion before removing the request - request_title = information_request.title - - # Store the actual request ID from the information_request object for reliable operations - actual_request_id = information_request.request_id - - # Log the deletion in the project log - await ProjectStorage.log_project_event( - context=self.context, - project_id=project_id, - entry_type=LogEntryType.REQUEST_DELETED.value, - message=f"Information request '{request_title}' was deleted by {current_username}", - related_entity_id=actual_request_id, - metadata={ - "request_title": request_title, - "deleted_by": current_user_id, - "deleted_by_name": current_username, - }, - ) - - # Delete the information request - implementing deletion logic by removing the file - # Using ProjectStorage instead of direct path access - # Create information requests directory path and remove the specific file - - request_path = ProjectStorageManager.get_information_request_path(project_id, actual_request_id) - if request_path.exists(): - request_path.unlink() # Delete the file - - # Notify Coordinator about the deletion - try: - # Get Coordinator conversation ID - - coordinator_dir = ProjectStorageManager.get_project_dir(project_id) / ConversationRole.COORDINATOR.value - if coordinator_dir.exists(): - role_file = coordinator_dir / "conversation_role.json" - if role_file.exists(): - role_data = read_model(role_file, ConversationProjectManager.ConversationRoleInfo) - if role_data: - coordinator_conversation_id = role_data.conversation_id - - # Notify Coordinator - - client = ConversationClientManager.get_conversation_client( - self.context, coordinator_conversation_id - ) - await client.send_messages( - NewConversationMessage( - content=f"Team member ({current_username}) has deleted their request: '{request_title}'", - message_type=MessageType.notice, - ) - ) - except Exception as e: - logger.warning(f"Could not notify Coordinator about deleted request: {e}") - # Not critical, so we continue - - # Update all project UI inspectors - await ProjectStorage.refresh_all_project_uis(self.context, project_id) - - return f"Information request '{request_title}' has been successfully deleted." - - except Exception as e: - logger.exception(f"Error deleting information request: {e}") - return f"Error deleting information request: {str(e)}. Please try again later." - - async def add_project_goal(self, goal_name: str, goal_description: str, success_criteria: List[str]) -> str: - """ - Add a goal to the project brief with measurable success criteria. - - Project goals should be operational objectives that team members will need to complete. - Each goal must have clear, measurable success criteria that team members can mark as completed. - - WHEN TO USE: - - When defining actionable goals that team members need to accomplish - - When breaking down project requirements into specific, achievable objectives - - After creating a project brief, before marking the project ready for working - - When users ask to add or define goals, objectives, or tasks for the project - - Args: - goal_name: A concise, clear name for the goal (e.g., "Implement User Authentication") - goal_description: A detailed description explaining what needs to be accomplished - success_criteria: List of specific, measurable criteria that indicate when the goal is complete - (e.g., ["User login form created", "Password reset functionality implemented"]) - - Returns: - A message indicating success or failure - """ - - if self.role is not ConversationRole.COORDINATOR: - return "Only Coordinator can add project goals." - - # Get project ID - project_id = await ProjectManager.get_project_id(self.context) - if not project_id: - return "No project associated with this conversation. Please create a project brief first." - - # Get existing project brief - brief = await ProjectManager.get_project_brief(self.context) - if not brief: - return "No project brief found. Please create one first with create_project_brief." - - # Use the formatted command processor from chat.py to leverage existing functionality - criteria_str = "" - if len(success_criteria) > 0: - criteria_str = "|" + ";".join(success_criteria) - - command_content = f"/add-goal {goal_name}|{goal_description}{criteria_str}" - - return await invoke_command_handler( - context=self.context, - command_content=command_content, - handler_func=handle_add_goal_command, - success_message=f"Goal '{goal_name}' added to project brief successfully.", - error_prefix="Error adding goal", - ) - - async def delete_project_goal(self, goal_index: int) -> str: - """ - Delete a goal from the project by index. - - WHEN TO USE: - - When a user explicitly requests to remove or delete a specific project goal - - When goals need to be reorganized and redundant/obsolete goals removed - - When a goal was added by mistake or is no longer relevant to the project - - Only before marking the project as ready for working - - NOTE: This action is irreversible and will remove all success criteria associated with the goal. - First use get_project_info() to see the list of goals and their indices before deletion. - - Args: - goal_index: The index of the goal to delete (0-based integer). Use get_project_info() first to see the - correct indices of goals. For example, to delete the first goal, use goal_index=0. - - Returns: - A message indicating success or failure - """ - - if self.role is not ConversationRole.COORDINATOR: - return "Only Coordinator can delete project goals." - - # Get project ID - validate project exists - project_id = await ProjectManager.get_project_id(self.context) - if not project_id: - return "No project associated with this conversation." - - # Call the ProjectManager method to delete the goal - success, result = await ProjectManager.delete_project_goal( - context=self.context, - goal_index=goal_index, - ) - - if success: - # Notify the user about the successful deletion - await self.context.send_messages( - NewConversationMessage( - content=f"Goal '{result}' has been successfully deleted from the project.", - message_type=MessageType.notice, - ) - ) - return f"Goal '{result}' has been successfully deleted from the project." - else: - # Return the error message - return f"Error deleting goal: {result}" - - async def mark_criterion_completed(self, goal_index: int, criterion_index: int) -> str: - """ - Mark a success criterion as completed for tracking project progress. - - WHEN TO USE: - - When the user reports completing a specific task or deliverable - - When evidence has been provided that a success criterion has been met - - When a milestone for one of the project goals has been achieved - - When tracking progress and updating the project status - - Each completed criterion moves the project closer to completion. When all criteria - are completed, the project can be marked as complete. - - IMPORTANT: Always use get_project_info() first to see the current goals, criteria, and their indices - before marking anything as complete. - - Args: - goal_index: The index of the goal (0-based integer) from get_project_info() output - criterion_index: The index of the criterion within the goal (0-based integer) - - Returns: - A message indicating success or failure - """ - - if self.role is not ConversationRole.TEAM: - return "Only Team members can mark criteria as completed." - - # Get project ID - project_id = await ProjectManager.get_project_id(self.context) - if not project_id: - return "No project associated with this conversation. Unable to mark criterion as completed." - - # Get existing project brief - brief = await ProjectManager.get_project_brief(self.context) - if not brief: - return "No project brief found." - - # Using 0-based indexing directly, no adjustment needed - - # Get the project to access goals - project = ProjectStorage.read_project(project_id) - if not project or not project.goals: - return "No project goals found." - - # Validate indices - if goal_index < 0 or goal_index >= len(project.goals): - return f"Invalid goal index {goal_index}. Valid indexes are 0 to {len(project.goals) - 1}. There are {len(project.goals)} goals." - - goal = project.goals[goal_index] - - if criterion_index < 0 or criterion_index >= len(goal.success_criteria): - return f"Invalid criterion index {criterion_index}. Valid indexes for goal '{goal.name}' are 0 to {len(goal.success_criteria) - 1}. Goal '{goal.name}' has {len(goal.success_criteria)} criteria." - - # Update the criterion - criterion = goal.success_criteria[criterion_index] - - if criterion.completed: - return f"Criterion '{criterion.description}' is already marked as completed." - - # Get current user information - participants = await self.context.get_participants() - current_user_id = None - - for participant in participants.participants: - if participant.role == "user": - current_user_id = participant.id - break - - if not current_user_id: - return "Could not identify current user." - - # Mark as completed - criterion.completed = True - criterion.completed_at = datetime.utcnow() - criterion.completed_by = current_user_id - - # Save the updated project with the completed criterion - ProjectStorage.write_project(project_id, project) - - # Log the criterion completion - await ProjectStorage.log_project_event( - context=self.context, - project_id=project_id, - entry_type=LogEntryType.CRITERION_COMPLETED.value, - message=f"Success criterion completed: {criterion.description}", - related_entity_id=None, - metadata={"goal_name": goal.name, "criterion_description": criterion.description}, - ) - - # Update project info - project_info = ProjectStorage.read_project_info(project_id) - - if project_info: - # Count all completed criteria - completed_criteria = 0 - total_criteria = 0 - - # Get the project to access goals - project = ProjectStorage.read_project(project_id) - if project and project.goals: - for g in project.goals: - total_criteria += len(g.success_criteria) - completed_criteria += sum(1 for c in g.success_criteria if c.completed) - - # Update project info with criteria stats - project_info.completed_criteria = completed_criteria - project_info.total_criteria = total_criteria - - # Calculate progress percentage - if total_criteria > 0: - project_info.progress_percentage = int((completed_criteria / total_criteria) * 100) - - # Update metadata - project_info.updated_at = datetime.utcnow() - project_info.updated_by = current_user_id - project_info.version += 1 - - # Save the updated project info - ProjectStorage.write_project_info(project_id, project_info) - - # Notify linked conversations with a message - await ProjectNotifier.notify_project_update( - context=self.context, - project_id=project_id, - update_type="project_info", - message=f"Success criterion '{criterion.description}' for goal '{goal.name}' has been marked as completed.", - ) - - # Update all project UI inspectors - await ProjectStorage.refresh_all_project_uis(self.context, project_id) - - # Check if all criteria are completed for project completion - # Count all completed criteria again to check for completion - completed = 0 - total = 0 - - # Get the project to access goals - project = ProjectStorage.read_project(project_id) - if project and project.goals: - for g in project.goals: - total += len(g.success_criteria) - completed += sum(1 for c in g.success_criteria if c.completed) - - if completed == total and total > 0: - # Automatically complete the project - success, project_info = await ProjectManager.complete_project( - context=self.context, - summary=f"All {total} success criteria have been completed! Project has been automatically marked as complete.", - ) - - if success: - await self.context.send_messages( - NewConversationMessage( - content="🎉 All success criteria have been completed! The project has been automatically marked as complete.", - message_type=MessageType.notice, - ) - ) - else: - await self.context.send_messages( - NewConversationMessage( - content="🎉 All success criteria have been completed! Would you like me to formally complete the project?", - message_type=MessageType.notice, - ) - ) - - await self.context.send_messages( - NewConversationMessage( - content=f"Success criterion '{criterion.description}' for goal '{goal.name}' has been marked as completed.", - message_type=MessageType.notice, - ) - ) - - return f"Criterion '{criterion.description}' for goal '{goal.name}' marked as completed." - - async def mark_project_ready_for_working(self) -> str: - """ - Mark the project as ready for working. - This is a milestone function that transitions from Planning Stage to Working Stage. - - Returns: - A message indicating success or failure - """ - - if self.role is not ConversationRole.COORDINATOR: - return "Only Coordinator can mark a project as ready for working." - - # Get project ID - project_id = await ProjectManager.get_project_id(self.context) - if not project_id: - return "No project associated with this conversation. Unable to mark project as ready for working." - - # Get existing project brief, whiteboard, and project - brief = ProjectStorage.read_project_brief(project_id) - whiteboard = ProjectStorage.read_project_whiteboard(project_id) - project = ProjectStorage.read_project(project_id) - - if not brief: - return "No project brief found. Please create one before marking as ready for working." - - if not project or not project.goals: - return "Project has no goals. Please add at least one goal before marking as ready for working." - - # Check if at least one goal has success criteria - has_criteria = False - for goal in project.goals: - if goal.success_criteria: - has_criteria = True - break - - if not has_criteria: - return "No success criteria defined. Please add at least one success criterion to a goal before marking as ready for working." - - # Check if whiteboard has content - if not whiteboard or not whiteboard.content: - return "Project whiteboard is empty. Content will be automatically generated as the project progresses." - - # Get or create project info - project_info = ProjectStorage.read_project_info(project_id) - - # Get current user information - participants = await self.context.get_participants() - current_user_id = None - - for participant in participants.participants: - if participant.role == "user": - current_user_id = participant.id - break - - if not current_user_id: - return "Could not identify current user." - - if not project_info: - # Create new project info if it doesn't exist - project_info = ProjectInfo( - project_id=project_id, - coordinator_conversation_id=str(self.context.id), - state=ProjectState.PLANNING, - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), - ) - - # Update state to ready_for_working - if isinstance(project_info, dict): - # Handle the dict case for backward compatibility - project_info["state"] = ProjectState.READY_FOR_WORKING - project_info["status_message"] = "Project is now ready for team operations" - project_info["updated_at"] = datetime.utcnow() - else: - # Handle the ProjectInfo case - project_info.state = ProjectState.READY_FOR_WORKING - project_info.status_message = "Project is now ready for team operations" - project_info.updated_at = datetime.utcnow() - - # Save the updated project info - ProjectStorage.write_project_info(project_id, project_info) - - # Log the milestone transition - await ProjectStorage.log_project_event( - context=self.context, - project_id=project_id, - entry_type=LogEntryType.MILESTONE_PASSED.value, - message="Project marked as READY FOR WORKING", - metadata={"milestone": "ready_for_working"}, - ) - - # Notify linked conversations with a message - await ProjectNotifier.notify_project_update( - context=self.context, - project_id=project_id, - update_type="project_info", - message="🔔 **Project Milestone Reached**: Coordinator has marked the project as READY FOR WORKING. All project information is now available and you can begin team operations.", - ) - - # Update all project UI inspectors - await ProjectStorage.refresh_all_project_uis(self.context, project_id) - - await self.context.send_messages( - NewConversationMessage( - content="🎯 Project has been marked as READY FOR WORKING. Team members have been notified and can now begin operations.", - message_type=MessageType.chat, - ) - ) - - return "Project successfully marked as ready for team operations." - - async def report_project_completion(self) -> str: - """ - Report that the project is complete, concluding the project lifecycle. - - WHEN TO USE: - - When all success criteria for all goals have been marked as completed - - When the user confirms the project deliverables are finished and ready - - When the project objectives have been fully achieved - - When it's time to formally conclude the project - - This is a significant milestone that indicates the project has successfully - completed all its goals. Before using this tool, verify that all success criteria - have been marked as completed using get_project_info(). - - Returns: - A message indicating success or failure - """ - - if self.role is not ConversationRole.TEAM: - return "Only Team members can report project completion." - - # Get project ID - project_id = await ProjectManager.get_project_id(self.context) - if not project_id: - return "No project associated with this conversation. Unable to report project completion." - - # Get existing project info - project_info = ProjectStorage.read_project_info(project_id) - if not project_info: - return "No project information found. Cannot complete project without project information." - - # Check if all criteria are completed - if getattr(project_info, "completed_criteria", 0) < getattr(project_info, "total_criteria", 0): - remaining = project_info.total_criteria - project_info.completed_criteria - return f"Cannot complete project - {remaining} success criteria are still pending completion." - - # Get current user information - participants = await self.context.get_participants() - current_user_id = None - - for participant in participants.participants: - if participant.role == "user": - current_user_id = participant.id - break - - if not current_user_id: - return "Could not identify current user." - - # Update project info to completed - project_info.state = ProjectState.COMPLETED - project_info.progress_percentage = 100 - project_info.status_message = "Project is now complete" - - # Add lifecycle metadata - if not hasattr(project_info, "lifecycle") or not project_info.lifecycle: - project_info.lifecycle = {} - - project_info.lifecycle["project_completed"] = True - project_info.lifecycle["project_completed_time"] = datetime.utcnow().isoformat() - project_info.lifecycle["project_completed_by"] = current_user_id - - # Update metadata - project_info.updated_at = datetime.utcnow() - project_info.updated_by = current_user_id - project_info.version += 1 - - # Save the updated project info - ProjectStorage.write_project_info(project_id, project_info) - - # Log the milestone transition - await ProjectStorage.log_project_event( - context=self.context, - project_id=project_id, - entry_type=LogEntryType.PROJECT_COMPLETED.value, - message="Project marked as COMPLETED", - metadata={"milestone": "project_completed"}, - ) - - # Notify linked conversations with a message - await ProjectNotifier.notify_project_update( - context=self.context, - project_id=project_id, - update_type="project_completed", - message="🎉 **Project Complete**: Team has reported that all project objectives have been achieved. The project is now complete.", - ) - - # Update all project UI inspectors - await ProjectStorage.refresh_all_project_uis(self.context, project_id) - - await self.context.send_messages( - NewConversationMessage( - content="🎉 **Project Complete**: All objectives have been achieved and the project is now complete. The Coordinator has been notified.", - message_type=MessageType.chat, - ) - ) - - return "Project successfully marked as complete. All participants have been notified." - - async def suggest_next_action(self) -> Dict[str, Any]: - """ - Suggest the next action the user should take based on project state. - - Returns: - Dict with suggestion details - """ - # Get project ID - project_id = await ProjectManager.get_project_id(self.context) - if not project_id: - logger.warning("No project ID found for this conversation") - return { - "suggestion": "no_project", - "reason": "No project associated with this conversation. Unable to suggest next action.", - "priority": "low", - "function": None, - } - - project_info = ProjectStorage.read_project_info(project_id) - if not project_info: - return { - "suggestion": "no_project_info", - "reason": "No project information found. Unable to suggest next action.", - "priority": "low", - "function": None, - } - - # Get project state information - brief = ProjectStorage.read_project_brief(project_id) - project = ProjectStorage.read_project(project_id) - requests = ProjectStorage.get_all_information_requests(project_id) - - # Check if project brief exists - if not brief: - if self.role is ConversationRole.COORDINATOR: - return { - "suggestion": "create_project_brief", - "reason": "No project brief found. Start by creating one.", - "priority": "high", - "function": "create_project_brief", - "parameters": {"name": "", "description": ""}, - } - else: - return { - "suggestion": "wait_for_coordinator", - "reason": "No project brief found. The Coordinator needs to create one before you can proceed.", - "priority": "medium", - "function": None, - } - - # Check if goals exist - if not project or not project.goals: - if self.role is ConversationRole.COORDINATOR: - return { - "suggestion": "add_project_goal", - "reason": "Project has no goals. Add at least one goal with success criteria.", - "priority": "high", - "function": "add_project_goal", - "parameters": {"goal_name": "", "goal_description": "", "success_criteria": []}, - } - else: - return { - "suggestion": "wait_for_goals", - "reason": "Project has no goals. The Coordinator needs to add goals before you can proceed.", - "priority": "medium", - "function": None, - } - - # Check project info if project is ready for working - ready_for_working = project_info.state == ProjectState.READY_FOR_WORKING - - if not ready_for_working and self.role is ConversationRole.COORDINATOR: - # Check if it's ready to mark as ready for working - has_goals = bool(project and project.goals) - has_criteria = bool( - project and project.goals and any(bool(goal.success_criteria) for goal in project.goals) - ) - - if has_goals and has_criteria: - return { - "suggestion": "mark_ready_for_working", - "reason": "Project information is complete. Mark it as ready for team operations.", - "priority": "medium", - "function": "mark_project_ready_for_working", - "parameters": {}, - } - - # Check for unresolved information requests for Coordinator - if self.role is ConversationRole.COORDINATOR: - active_requests = [r for r in requests if r.status == RequestStatus.NEW] - if active_requests: - request = active_requests[0] # Get the first unresolved request - return { - "suggestion": "resolve_information_request", - "reason": f"There are {len(active_requests)} unresolved information requests. Consider resolving '{request.title}'.", - "priority": "high" - if request.priority in [RequestPriority.HIGH, RequestPriority.CRITICAL] - else "medium", - "function": "resolve_information_request", - "parameters": {"request_id": request.request_id, "resolution": ""}, - } - - # For team, check if all criteria are completed for project completion - criteria = await ProjectManager.get_project_criteria(self.context) - incomplete_criteria = [criterion for criterion in criteria if not criterion.completed] - - if self.role is ConversationRole.TEAM and not incomplete_criteria: - return { - "suggestion": "report_project_completion", - "reason": "All success criteria have been completed. Report project completion.", - "priority": "medium", - "function": "report_project_completion", - "parameters": {}, - } - - # For team, suggest marking criteria as completed if any are pending - if self.role is ConversationRole.TEAM and incomplete_criteria: - # Get the project to access goals - project = ProjectStorage.read_project(project_id) - if project and project.goals: - # Find the first uncompleted criterion - for goal_index, goal in enumerate(project.goals): - for criterion_index, criterion in enumerate(goal.success_criteria): - if not criterion.completed: - return { - "suggestion": "mark_criterion_completed", - "reason": "Update progress by marking completed success criteria.", - "priority": "low", - "function": "mark_criterion_completed", - "parameters": { - "goal_index": goal_index, # 0-based indexing - "criterion_index": criterion_index, # 0-based indexing - }, - } - - # Default suggestions based on role - if self.role is ConversationRole.COORDINATOR: - return { - "suggestion": "monitor_progress", - "reason": "Monitor team operations and respond to any new information requests.", - "priority": "low", - "function": None, - } - else: - return { - "suggestion": "update_status", - "reason": "Continue team operations and update project progress as you make advancements.", - "priority": "low", - "function": "update_project_info", - "parameters": {"status": "in_progress"}, - } diff --git a/assistants/project-assistant/assistant/tools/__init__.py b/assistants/project-assistant/assistant/tools/__init__.py new file mode 100644 index 000000000..8f2d67117 --- /dev/null +++ b/assistants/project-assistant/assistant/tools/__init__.py @@ -0,0 +1,147 @@ +""" +Tools directory for Knowledge Transfer Assistant. + +This module provides the ShareTools class that aggregates all tool functionality +and registers role-specific tools with the LLM. +""" + +from openai_client.tools import ToolFunctions +from semantic_workbench_assistant.assistant_app import ConversationContext + +from assistant.tools.conversation_preferences import ConversationPreferencesTools +from assistant.tools.tasks import TaskTools + +from .information_requests import InformationRequestTools +from .learning_outcomes import LearningOutcomeTools +from .progress_tracking import ProgressTrackingTools +from .share_setup import ShareSetupTools + + +class ShareTools: + """Tools for the Knowledge Transfer Assistant to use during chat completions.""" + + def __init__(self, context: ConversationContext): + self.context = context + self._tool_instances = None + + def act_tools(self) -> ToolFunctions: + fns = ToolFunctions() + + # fns.add_function( + # InformationRequestTools(self.context).request_information_from_user, + # "request_information_from_user", + # ) + + fns.add_function( + TaskTools(self.context).add_task, + "add_task", + ) + + fns.add_function( + TaskTools(self.context).update_task, + "update_task", + ) + + fns.add_function( + ShareSetupTools(self.context).update_audience, + "update_audience", + ) + + fns.add_function( + ShareSetupTools(self.context).update_audience_takeaways, + "update_audience_takeaways", + ) + + fns.add_function( + ShareSetupTools(self.context).update_brief, + "update_brief", + ) + + fns.add_function( + ShareSetupTools(self.context).create_invitation_message, + "create_invitation_message", + ) + + # tool_functions.add_function( + # ShareSetupTools(self.context).set_knowledge_organized, + # "set_knowledge_organized", + # ) + + # tool_functions.add_function( + # ShareSetupTools(self.context).set_learning_intention, + # "set_learning_intention", + # ) + # tool_functions.add_function( + # LearningObjectiveTools(self.context).add_learning_objective, + # "add_learning_objective", + # ) + # tool_functions.add_function( + # LearningObjectiveTools(self.context).update_learning_objective, + # "update_learning_objective", + # ) + # tool_functions.add_function( + # LearningObjectiveTools(self.context).delete_learning_objective, + # "delete_learning_objective", + # ) + + # tool_functions.add_function( + # LearningOutcomeTools(self.context).add_learning_outcome, + # "add_learning_outcome", + # ) + # tool_functions.add_function( + # LearningOutcomeTools(self.context).update_learning_outcome, + # "update_learning_outcome", + # ) + # tool_functions.add_function( + # LearningOutcomeTools(self.context).delete_learning_outcome, + # "delete_learning_outcome", + # "Delete a learning outcome by outcome ID", + # ) + + return fns + + def conversationalist_tools(self) -> ToolFunctions: + """Return coordinator-specific tool functions.""" + fns = ToolFunctions() + + fns.add_function( + InformationRequestTools(self.context).request_information_from_user, + "request_information_from_user", + ) + + fns.add_function( + InformationRequestTools(self.context).resolve_information_request, + "resolve_information_request", + ) + + return fns + + def team_tools(self) -> ToolFunctions: + """Return team-specific tool functions.""" + tool_functions = ToolFunctions() + + tool_functions.add_function( + ConversationPreferencesTools(self.context).update_preferred_communication_style, + "update_preferred_communication_style", + ) + tool_functions.add_function( + InformationRequestTools(self.context).request_information_from_coordinator, + "create_information_request", + ) + tool_functions.add_function( + InformationRequestTools(self.context).delete_information_request, + "delete_information_request", + ) + tool_functions.add_function( + LearningOutcomeTools(self.context).mark_learning_outcome_achieved, + "mark_learning_outcome_achieved", + ) + tool_functions.add_function( + ProgressTrackingTools(self.context).report_transfer_completion, + "report_transfer_completion", + ) + + return tool_functions + + +__all__ = ["ShareTools"] diff --git a/assistants/project-assistant/assistant/tools/base.py b/assistants/project-assistant/assistant/tools/base.py new file mode 100644 index 000000000..a1426443e --- /dev/null +++ b/assistants/project-assistant/assistant/tools/base.py @@ -0,0 +1,19 @@ +""" +Base classes for Knowledge Transfer Assistant tools. +""" + +from semantic_workbench_assistant.assistant_app import ConversationContext + + +class ToolsBase: + """Base class for tool functionality.""" + + def __init__(self, context: ConversationContext): + """ + Initialize the tools base with the current conversation context. + + Args: + context: The conversation context + role: The assistant's role (ConversationRole enum) + """ + self.context = context diff --git a/assistants/project-assistant/assistant/tools/conversation_preferences.py b/assistants/project-assistant/assistant/tools/conversation_preferences.py new file mode 100644 index 000000000..e914c9c51 --- /dev/null +++ b/assistants/project-assistant/assistant/tools/conversation_preferences.py @@ -0,0 +1,30 @@ +""" +Project setup tools for Knowledge Transfer Assistant. + +Tools for configuring knowledge shares. +""" + +from assistant.domain.conversation_preferences_manager import ConversationPreferencesManager + +from .base import ToolsBase + + +class ConversationPreferencesTools(ToolsBase): + async def update_preferred_communication_style(self, style: str) -> str: + """ + Update the preferred communication style for the conversation. + + Args: + style: The preferred communication style to set. This should include all of the users instructions about how the assistant should communicate with the user. It is not just a single style, but a comprehensive set of instructions. + + Returns: + A message indicating success or failure + """ # noqa: E501 + try: + await ConversationPreferencesManager.update_preferred_communication_style( + context=self.context, + preferred_communication_style=style, + ) + return "Preferred conversation style updated successfully" + except Exception as e: + return f"Failed to update preferred conversation style: {e!s}" diff --git a/assistants/project-assistant/assistant/tools/information_requests.py b/assistants/project-assistant/assistant/tools/information_requests.py new file mode 100644 index 000000000..8f47e2fc4 --- /dev/null +++ b/assistants/project-assistant/assistant/tools/information_requests.py @@ -0,0 +1,165 @@ +""" +Information request tools for Knowledge Transfer Assistant. + +Tools for creating, managing, and resolving information requests between coordinators and team members. +""" + +from typing import Literal + +from assistant.data import InformationRequestSource, RequestPriority +from assistant.domain import InformationRequestManager +from assistant.logging import logger + +from .base import ToolsBase + + +class InformationRequestTools(ToolsBase): + """Tools for managing information requests.""" + + async def request_information_from_coordinator( + self, + title: str, + description: str, + priority: Literal["low", "medium", "high", "critical"], + ) -> str: + """ + Create an information request to send to the Coordinator for information that is unavailable to you or to report a blocker. + + WHEN TO USE: + - When you need specific information or clarification from the Coordinator + - When encountering a blocker that prevents progress on satisfying an intended takeaway + - When requesting additional resources or documentation + - When you need a decision from the Coordinator + - When a user expressly asks for information or help with something unclear + + Set an appropriate priority based on how critical the information is: + - "low": Nice to have, not blocking progress + - "medium": Important but not immediate + - "high": Important and somewhat urgent + - "critical": Completely blocked, cannot proceed without this information + + Args: + title: A concise, clear title that summarizes what information is needed + description: A detailed explanation of what information is needed and why it's important + priority: The priority level - must be one of: low, medium, high, critical + + Returns: + A message indicating success or failure + """ # noqa: E501 + + priority_map = { + "low": RequestPriority.LOW, + "medium": RequestPriority.MEDIUM, + "high": RequestPriority.HIGH, + "critical": RequestPriority.CRITICAL, + } + priority_enum = priority_map.get(priority.lower(), RequestPriority.MEDIUM) + + try: + await InformationRequestManager.create_information_request( + context=self.context, + title=title, + description=description, + priority=priority_enum, + source=InformationRequestSource.TEAM, + ) + return f"Information request '{title}' created successfully. The Coordinator has been notified." + except Exception as e: + logger.exception(f"Failed to create information request: {e}") + return f"Failed to create information request: {e!s}" + + async def request_information_from_user( + self, + title: str, + description: str, + priority: Literal["low", "medium", "high", "critical"], + ) -> str: + """ + Request specific information from the user that you don't already have access to so that you can complete a task. + + WHEN TO USE: + - When you need specific information or clarification from the user to complete a task. + - When encountering a blocker that prevents progress on a task. + - When requesting additional resources or documentation. + - When you need a decision from the user. + + Set an appropriate priority based on how critical the information is: + - "low": Nice to have, not blocking progress. + - "medium": Important but not immediate. + - "high": Important and somewhat urgent. + - "critical": Completely blocked, cannot proceed without this information. + + Args: + title: A concise, clear title that summarizes what information is needed. + description: A detailed explanation of what specific information is needed and why it's important. + priority: The priority level - must be one of: low, medium, high, critical. + + Returns: + A message indicating success or failure + """ # noqa: E501 + + priority_map = { + "low": RequestPriority.LOW, + "medium": RequestPriority.MEDIUM, + "high": RequestPriority.HIGH, + "critical": RequestPriority.CRITICAL, + } + priority_enum = priority_map.get(priority.lower(), RequestPriority.MEDIUM) + + try: + await InformationRequestManager.create_information_request( + context=self.context, + title=title, + description=description, + priority=priority_enum, + source=InformationRequestSource.INTERNAL, + ) + return f"Information request '{title}' created successfully. The Coordinator has been notified." + except Exception as e: + logger.exception(f"Failed to create information request: {e}") + return f"Failed to create information request: {e!s}" + + async def resolve_information_request(self, request_id: str, resolution: str) -> str: + """ + Resolve an information request when you have the needed information to address it. Only use for active information requests. If there are no active information requests, this should never be called. + + WHEN TO USE: + - When you already have information that directly answers the request. + - When you have gathered enough details to answer the request. + - When a request is no longer relevant and should be closed with explanation. + + Args: + request_id: The UUID of the information request to resolve + resolution: Complete information that addresses the request + + Returns: + A message indicating success or failure + """ # noqa: E501 + try: + information_request = await InformationRequestManager.resolve_information_request( + context=self.context, request_id=request_id, resolution=resolution + ) + return f"Information request '{information_request.title}' has been resolved." + except Exception as e: + logger.exception(f"Failed to resolve information request: {e}") + return f"ERROR: Could not resolve information request with ID '{request_id}': {e!s}" + + async def delete_information_request(self, request_id: str) -> str: + """ + Delete an information request that is no longer needed. + This completely removes the request from the system. + + Args: + request_id: ID of the request to delete + + Returns: + Message indicating success or failure + """ + try: + message = await InformationRequestManager.delete_information_request( + context=self.context, request_id=request_id + ) + return message + except Exception as e: + logger.exception(f"Failed to delete information request: {e}") + return f"Failed to delete information request: {e!s}" diff --git a/assistants/project-assistant/assistant/tools/learning_objectives.py b/assistants/project-assistant/assistant/tools/learning_objectives.py new file mode 100644 index 000000000..4362ae6cf --- /dev/null +++ b/assistants/project-assistant/assistant/tools/learning_objectives.py @@ -0,0 +1,105 @@ +""" +Learning objectives management tools for Knowledge Transfer Assistant. + +Tools for creating, updating, and managing learning objectives and outcomes. +""" + +from assistant.domain import LearningObjectivesManager +from assistant.logging import logger + +from .base import ToolsBase + + +class LearningObjectiveTools(ToolsBase): + """Tools for managing learning objectives and outcomes.""" + + async def add_learning_objective(self, objective_name: str, description: str, learning_outcomes: list[str]) -> str: + """ + Add a learning objective with measurable learning outcomes. + + - Learning objectives should define what knowledge areas learners need to understand. + - Each objective must have clear, measurable learning outcomes that learners can mark as achieved. + + WHEN TO USE: + - When defining what knowledge areas team members need to understand + - When breaking down knowledge requirements into specific, learnable objectives + - After creating a knowledge brief, before marking the transfer ready for learning + - When users ask to add or define learning objectives or knowledge areas + + Args: + objective_name: A concise, clear name for the learning objective (e.g., "Understanding User Authentication") + description: A detailed description explaining what knowledge needs to be understood + learning_outcomes: List of specific, measurable outcomes that indicate when the objective is achieved + (e.g., ["Can explain authentication flow", "Can implement password security"]) + + Returns: + A message indicating success or failure + """ + objective = await LearningObjectivesManager.add_learning_objective( + context=self.context, + objective_name=objective_name, + description=description, + outcomes=learning_outcomes, + ) + + if objective: + return f"Learning objective '{objective_name}' added to knowledge brief successfully." + else: + return "Failed to add learning objective. Please try again." + + async def update_learning_objective( + self, + objective_id: str, + objective_name: str, + description: str, + ) -> str: + """ + Update an existing learning objective's name or description. + + Args: + objective_id: The UUID of the learning objective to update + objective_name: New name for the objective (empty string to keep current name) + description: New description (empty string to keep current description) + + Returns: + A message indicating success or failure + """ + try: + message = await LearningObjectivesManager.update_learning_objective( + context=self.context, + objective_id=objective_id, + objective_name=objective_name if objective_name.strip() else None, + description=description if description.strip() else None, + ) + return message + except Exception as e: + logger.exception(f"Failed to update learning objective: {e}") + return f"Failed to update learning objective: {e!s}" + + async def delete_learning_objective(self, objective_id: str) -> str: + """ + Delete a learning objective from the knowledge package by ID. + + WHEN TO USE: + - When a user explicitly requests to remove or delete a specific learning objective + - When objectives need to be reorganized and redundant/obsolete objectives removed + - When an objective was added by mistake or is no longer relevant to the knowledge transfer + - Only before marking the knowledge package as ready for transfer + + NOTE: This action is irreversible and will remove all learning outcomes associated with the objective. + + Args: + objective_id: The UUID of the learning objective to delete. + + Returns: + A message indicating success or failure + """ + try: + message = await LearningObjectivesManager.delete_learning_objective( + context=self.context, + objective_id=objective_id, + ) + return message + except Exception as e: + logger.exception(f"Failed to delete learning objective: {e}") + return f"Failed to delete learning objective: {e!s}" diff --git a/assistants/project-assistant/assistant/tools/learning_outcomes.py b/assistants/project-assistant/assistant/tools/learning_outcomes.py new file mode 100644 index 000000000..4d27a8f6e --- /dev/null +++ b/assistants/project-assistant/assistant/tools/learning_outcomes.py @@ -0,0 +1,232 @@ +""" +Learning outcomes management tools for Knowledge Transfer Assistant. + +Tools for managing individual learning outcomes within objectives. +""" + +from datetime import UTC, datetime + +from semantic_workbench_api_model.workbench_model import ( + MessageType, + NewConversationMessage, +) + +from assistant.data import ( + InspectorTab, + LearningOutcomeAchievement, + LogEntryType, +) +from assistant.domain import LearningObjectivesManager, ShareManager, TransferManager +from assistant.logging import logger +from assistant.notifications import Notifications +from assistant.utils import get_current_user_id + +from .base import ToolsBase + + +class LearningOutcomeTools(ToolsBase): + """Tools for managing learning outcomes.""" + + async def add_learning_outcome(self, objective_id: str, outcome_description: str) -> str: + """ + Add a new learning outcome to an existing learning objective. + + WHEN TO USE: + - When you need to add additional measurable outcomes to an existing objective + - When refining objectives by breaking them down into more specific outcomes + - When expanding the scope of an objective with new learning goals + - When iteratively developing learning objectives based on feedback + + Args: + objective_id: The UUID of the learning objective to add the outcome to + outcome_description: Clear, specific description of what needs to be understood or accomplished + + Returns: + A message indicating success or failure + """ + try: + message = await LearningObjectivesManager.add_learning_outcome( + context=self.context, + objective_id=objective_id, + outcome_description=outcome_description, + ) + return message + except Exception as e: + logger.exception(f"Failed to add learning outcome: {e}") + return f"Failed to add learning outcome: {e!s}" + + async def update_learning_outcome(self, outcome_id: str, new_description: str) -> str: + """ + Update the description of an existing learning outcome. + + WHEN TO USE: + - When clarifying or improving the wording of an existing outcome + - When making outcomes more specific or measurable + - When correcting errors in outcome descriptions + - When refining outcomes based on feedback or better understanding + + Args: + outcome_id: The unique ID of the learning outcome to update + new_description: New description for the learning outcome + + Returns: + A message indicating success or failure + """ + try: + message = await LearningObjectivesManager.update_learning_outcome( + context=self.context, + outcome_id=outcome_id, + new_description=new_description, + ) + return message + except Exception as e: + logger.exception(f"Failed to update learning outcome: {e}") + return f"Failed to update learning outcome: {e!s}" + + async def delete_learning_outcome(self, outcome_id: str) -> str: + """ + Delete a learning outcome from a learning objective. + + WHEN TO USE: + - When an outcome is no longer relevant or necessary + - When consolidating redundant outcomes + - When removing outcomes that were added by mistake + - When simplifying objectives by removing overly specific outcomes + + NOTE: This action is irreversible. + + Args: + outcome_id: The unique ID of the learning outcome to delete + + Returns: + A message indicating success or failure + """ + try: + message = await LearningObjectivesManager.delete_learning_outcome( + context=self.context, + outcome_id=outcome_id, + ) + return message + except Exception as e: + logger.exception(f"Failed to delete learning outcome: {e}") + return f"Failed to delete learning outcome: {e!s}" + + async def mark_learning_outcome_achieved(self, objective_id: str, outcome_id: str) -> str: + """ + Mark a learning outcome as achieved for tracking knowledge transfer progress. + + WHEN TO USE: + - When the user reports completing a specific learning task or deliverable + - When evidence has been provided that a learning outcome has been met + - When a milestone for one of the learning objectives has been achieved + - When tracking progress and updating the transfer status + + Each completed outcome moves the knowledge transfer closer to completion. When all outcomes are achieved, the transfer can be marked as complete. + + Args: + objective_id: The UUID of the learning objective + outcome_id: The UUID of the learning outcome within the objective + + Returns: + A message indicating success or failure + """ # noqa: E501 + try: + share = await ShareManager.get_share(self.context) + brief = share.brief + if not brief: + return "No knowledge brief found." + + # Find the objective by ID + objective = None + for obj in share.learning_objectives: + if obj.id == objective_id: + objective = obj + break + if not objective: + return f"Learning objective with ID '{objective_id}' not found." + + # Find the outcome by ID within the objective + outcome = None + for out in objective.learning_outcomes: + if out.id == outcome_id: + outcome = out + break + + if not outcome: + return f"Learning outcome with ID '{outcome_id}' not found in objective '{objective.name}'." + + conversation_id = str(self.context.id) + + # Check if already achieved by this conversation + if LearningObjectivesManager.is_outcome_achieved_by_conversation(share, outcome.id, conversation_id): + return f"Outcome '{outcome.description}' is already marked as achieved by this team member." + + # Ensure team conversation info exists + if conversation_id not in share.team_conversations: + return "Team conversation not properly registered. Please contact the coordinator." + + # Create achievement record + achievement = LearningOutcomeAchievement( + outcome_id=outcome.id, achieved=True, achieved_at=datetime.now(UTC) + ) + + # Add achievement to team conversation's achievements + share.team_conversations[conversation_id].outcome_achievements.append(achievement) + + # Update team conversation's last active timestamp + share.team_conversations[conversation_id].last_active_at = datetime.now(UTC) + + # Update metadata + current_user_id = await get_current_user_id(self.context) + share.updated_at = datetime.now(UTC) + share.updated_by = current_user_id + share.version += 1 + + # Save the updated knowledge package + await ShareManager.set_share(self.context, share) + + # Log the outcome achievement + await ShareManager.log_share_event( + context=self.context, + entry_type=LogEntryType.OUTCOME_ATTAINED.value, + message=f"Learning outcome achieved: {outcome.description}", + related_entity_id=None, + metadata={ + "objective_name": objective.name, + "outcome_description": outcome.description, + }, + ) + + # Notify linked conversations with a message + await Notifications.notify_all( + self.context, + share.share_id, + f"Learning outcome '{outcome.description}' for objective '{objective.name}' has been achieved.", + ) + await Notifications.notify_all_state_update( + self.context, + [InspectorTab.LEARNING, InspectorTab.BRIEF], + ) + + # Check if all outcomes are achieved for transfer completion + # Get the knowledge package to check completion status + if TransferManager._is_transfer_complete(share): + await self.context.send_messages( + NewConversationMessage( + content="🎉 All learning outcomes have been achieved! The knowledge transfer has been automatically marked as complete.", # noqa: E501 + message_type=MessageType.notice, + ) + ) + + await self.context.send_messages( + NewConversationMessage( + content=f"Learning outcome '{outcome.description}' for objective '{objective.name}' has been marked as achieved.", # noqa: E501 + message_type=MessageType.notice, + ) + ) + + return f"Learning outcome '{outcome.description}' for objective '{objective.name}' marked as achieved." + + except Exception as e: + logger.exception(f"Error marking learning outcome as achieved: {e}") + return "An error occurred while marking the learning outcome as achieved. Please try again later." diff --git a/assistants/project-assistant/assistant/tools/progress_tracking.py b/assistants/project-assistant/assistant/tools/progress_tracking.py new file mode 100644 index 000000000..ead7913ce --- /dev/null +++ b/assistants/project-assistant/assistant/tools/progress_tracking.py @@ -0,0 +1,88 @@ +""" +Progress tracking tools for Knowledge Transfer Assistant. + +Tools for tracking learning progress and completing knowledge transfer activities. +""" + +from datetime import UTC, datetime + +from semantic_workbench_api_model.workbench_model import ( + MessageType, + NewConversationMessage, +) + +from assistant.data import ( + InspectorTab, + LogEntryType, +) +from assistant.domain import LearningObjectivesManager, ShareManager +from assistant.logging import logger +from assistant.notifications import Notifications +from assistant.utils import get_current_user_id + +from .base import ToolsBase + + +class ProgressTrackingTools(ToolsBase): + """Tools for tracking learning progress.""" + + async def report_transfer_completion(self) -> str: + """ + Report that the knowledge transfer is complete, concluding the transfer lifecycle. + + WHEN TO USE: + - When all learning outcomes for all objectives have been marked as achieved + - When the user confirms the knowledge has been successfully learned + - When the learning objectives have been fully achieved + - When it's time to formally conclude the knowledge transfer + + This is a significant milestone that indicates the knowledge transfer has successfully achieved all its learning objectives. Before using this tool, verify that all learning outcomes have been marked as achieved. + + Returns: + A message indicating success or failure + """ # noqa: E501 + try: + share = await ShareManager.get_share(self.context) + + # Check if all outcomes are achieved + achieved_outcomes, total_outcomes = LearningObjectivesManager.get_overall_completion(share) + if achieved_outcomes < total_outcomes: + remaining = total_outcomes - achieved_outcomes + return ( + f"Cannot complete knowledge transfer - {remaining} learning outcomes are still pending achievement." + ) + + current_user_id = await get_current_user_id(self.context) + share.updated_at = datetime.now(UTC) + share.updated_by = current_user_id + share.version += 1 + await ShareManager.set_share(self.context, share) + + # Log the milestone transition + await ShareManager.log_share_event( + context=self.context, + entry_type=LogEntryType.SHARE_COMPLETED.value, + message="Transfer marked as COMPLETED", + metadata={"milestone": "transfer_completed"}, + ) + + # Notify linked conversations with a message + await Notifications.notify_all( + self.context, + share.share_id, + "🎉 **Knowledge Transfer Complete**: Team has reported that all learning objectives have been achieved. The knowledge transfer is now complete.", # noqa: E501 + ) + await Notifications.notify_all_state_update(self.context, [InspectorTab.BRIEF]) + + await self.context.send_messages( + NewConversationMessage( + content="🎉 **Knowledge Transfer Complete**: All learning objectives have been achieved and the knowledge transfer is now complete. The Coordinator has been notified.", # noqa: E501 + message_type=MessageType.chat, + ) + ) + + return "Knowledge transfer successfully marked as complete. All participants have been notified." + + except Exception as e: + logger.exception(f"Error reporting transfer completion: {e}") + return "An error occurred while reporting transfer completion. Please try again later." diff --git a/assistants/project-assistant/assistant/tools/share_setup.py b/assistants/project-assistant/assistant/tools/share_setup.py new file mode 100644 index 000000000..0dc881a6a --- /dev/null +++ b/assistants/project-assistant/assistant/tools/share_setup.py @@ -0,0 +1,158 @@ +""" +Project setup tools for Knowledge Transfer Assistant. + +Tools for configuring knowledge shares. +""" + +from datetime import UTC, datetime + +from assistant import agentic +from assistant.data import InspectorTab +from assistant.domain.audience_manager import AudienceManager +from assistant.domain.knowledge_brief_manager import KnowledgeBriefManager +from assistant.domain.share_manager import ShareManager +from assistant.notifications import Notifications + +from .base import ToolsBase + + +class ShareSetupTools(ToolsBase): + """Tools for the knowledge transfer setup and configuration.""" + + async def update_audience(self, audience_description: str) -> str: + """ + Update the target audience description for this knowledge transfer. + + Args: + audience_description: Description of the intended audience and their existing knowledge level + + Returns: + A message indicating success or failure + """ + try: + await AudienceManager.update_audience( + context=self.context, + audience_description=audience_description, + ) + await Notifications.notify(self.context, "Audience updated.") + await Notifications.notify_all_state_update(self.context, [InspectorTab.DEBUG]) + return "Audience updated successfully" + except Exception as e: + return f"Failed to update audience: {e!s}" + + async def update_audience_takeaways(self, takeaways: list[str]) -> str: + """ + Update the key takeaways for the target audience. + + Args: + takeaways: List of key takeaways for the audience. Takeaways should be concise and directly related to the audience and what the user wants them to learn or understand. + + Returns: + A message indicating success or failure + """ # noqa: E501 + try: + await AudienceManager.update_audience_takeaways( + context=self.context, + takeaways=takeaways, + ) + await Notifications.notify(self.context, "Audience takeaways updated.") + await Notifications.notify_all_state_update(self.context, [InspectorTab.BRIEF]) + return "Audience takeaways updated successfully" + except Exception as e: + return f"Failed to update audience takeaways: {e!s}" + + # async def set_knowledge_organized(self, is_organized: bool) -> str: + # """ + # Mark that all necessary knowledge has been captured and organized for transfer. + + # This indicates that the coordinator has uploaded files, shared information through conversation, and confirmed that all necessary knowledge for the transfer has been captured. This is required before the knowledge package can move to the "Ready for Transfer" state. # noqa: E501 + + # Args: + # is_organized: True if knowledge is organized and ready, False to + # mark as currently unorganized + + # Returns: + # A message indicating success or failure + # """ + # try: + # share = await ShareManager.get_share(self.context) + # share.knowledge_organized = is_organized + # share.updated_at = datetime.now(UTC) + # await ShareManager.set_share(self.context, share) + + # if is_organized: + # guidance = "Knowledge is now marked as organized and ready. You can proceed to create your brief and set up learning objectives." # noqa: E501 + # else: + # guidance = "Knowledge is now marked as incomplete. Continue organizing your knowledge by uploading files or describing it in conversation." # noqa: E501 + # return f"Knowledge organization status updated successfully. {guidance}" + + # except Exception as e: + # return f"Failed to update knowledge organization status: {e!s}" + + async def update_brief(self, title: str, content: str) -> str: + """ + Update a brief with a title and content. The brief should avoid + filler words and unnecessary content. + + Args: + title: The title of the brief description: A description of the + knowledge share to be given to recipients as context. + content: The content of the brief. + + Returns: + A message indicating success or failure + """ + try: + await KnowledgeBriefManager.update_knowledge_brief( + context=self.context, + title=title, + content=content, + ) + return "Brief updated successfully." + except Exception as e: + return f"Failed to update brief: {e!s}" + + async def set_learning_intention(self, is_for_specific_outcomes: bool) -> str: + """ + Set or update whether this knowledge package is intended for specific learning outcomes or general exploration. If intended for learning and an objective or outcome was provided, you should run the add_learning_objective function next (don't wait). + + Args: + is_for_specific_outcomes: True if this package should have learning + objectives and outcomes, False if this is for general exploration + + Returns: + A message indicating success or failure + """ # noqa: E501 + try: + share = await ShareManager.get_share(self.context) + share.is_intended_to_accomplish_outcomes = is_for_specific_outcomes + share.updated_at = datetime.now(UTC) + await ShareManager.set_share(self.context, share) + await Notifications.notify(self.context, "Knowledge share learning intention set.") + + # Provide appropriate guidance based on the choice + if is_for_specific_outcomes: + guidance = "This knowledge package is now set for specific learning outcomes. You'll need to add learning objectives with measurable outcomes." # noqa: E501 + else: + guidance = "This knowledge package is now set for general exploration. No specific learning objectives are required." # noqa: E501 + + return f"Learning intention updated successfully. {guidance}" + + except Exception as e: + return f"Failed to update learning intention: {e!s}" + + async def create_invitation_message(self) -> str: + """ + Create an invitation message to send to audience members for the knowledge transfer. + + Args: + invitation_text: The text of the invitation to be sent to the audience. + + Returns: + A message indicating success or failure + """ + try: + invitation = await agentic.create_invitation(self.context) + return invitation + except Exception as e: + return f"Failed to create invitation: {e!s}" diff --git a/assistants/project-assistant/assistant/tools/system_reminders.py b/assistants/project-assistant/assistant/tools/system_reminders.py new file mode 100644 index 000000000..adc6fd92c --- /dev/null +++ b/assistants/project-assistant/assistant/tools/system_reminders.py @@ -0,0 +1 @@ +kick_todo_usage = "The TodoWrite tool hasn't been used recently. If you're working on tasks that would benefit from tracking progress, consider using the TodoWrite tool to track progress. Only use it if it's relevant to the current work." # noqa: E501 diff --git a/assistants/project-assistant/assistant/tools/tasks.py b/assistants/project-assistant/assistant/tools/tasks.py new file mode 100644 index 000000000..3cd373e0c --- /dev/null +++ b/assistants/project-assistant/assistant/tools/tasks.py @@ -0,0 +1,89 @@ +from assistant.data import InspectorTab, NewTaskInfo, TaskInfo, TaskPriority, TaskStatus +from assistant.domain.tasks_manager import TasksManager +from assistant.logging import logger +from assistant.notifications import Notifications + +from .base import ToolsBase + + +class TaskTools(ToolsBase): + async def add_task(self, status: str, priority: str, content: str) -> str: + """ + Add a new task to the task list. + + Args: + status (str): The new status of the task. pending, in_progress, completed, or cancelled. + priority (TaskPriority): The new priority of the task. low, medium, or high. + content (str): The content of the task to update. + + Returns: + Message indicating success or failure + """ + try: + task_info = NewTaskInfo( + status=TaskStatus(status), + priority=TaskPriority(priority), + content=content, + ) + await TasksManager.add_tasks(self.context, [task_info]) + await Notifications.notify( + self.context, + "Task added.", + debug_data={"task_info": task_info}, + ) + await Notifications.notify_state_update(self.context, [InspectorTab.DEBUG]) + return "Task added successfully." + except Exception as e: + logger.exception(f"Failed to add task: {e}") + return f"Failed to add task: {e!s}" + + async def update_task(self, task_id: str, status: str, priority: str, content: str) -> str: + """ + Update a task's status, priority, or content. Use this for managing the task list. This should be called every time work has been done on a task or when the task needs to be updated. + + Args: + status (str): The new status of the task. pending, in_progress, completed, or cancelled. + priority (TaskPriority): The new priority of the task. low, medium, or high. + content (str): The content of the task to update. + + Returns: + Message indicating success or failure + """ # noqa: E501 + try: + task_info = TaskInfo( + task_id=task_id, + status=TaskStatus(status), + priority=TaskPriority(priority), + content=content, + ) + await TasksManager.update_task(self.context, task_info) + await Notifications.notify( + self.context, + "Task updated.", + debug_data={"task_info": task_info}, + ) + await Notifications.notify_state_update(self.context, [InspectorTab.DEBUG]) + return f"Task {task_info.task_id} updated successfully." + except Exception as e: + logger.exception(f"Failed to update task: {e}") + return f"Failed to update task: {e!s}" + + async def delete_task(self, task_id: str) -> str: + """ + Mark a task completed. This should be called EVERY TIME a task has been completed. + Args: + task (str): The task UUID to mark completed. + Returns: + Message indicating success or failure + """ + try: + await TasksManager.remove_task(self.context, task_id) + await Notifications.notify(self.context, "Task marked completed.", debug_data={"task": task_id}) + await Notifications.notify_state_update(self.context, [InspectorTab.DEBUG]) + message = f"Task marked completed: {task_id}" + logger.info(message) + return "Marked completed." + except Exception as e: + message = f"Failed to mark task completed: {e!s}" + logger.exception(message) + return message diff --git a/assistants/project-assistant/assistant/tools/todo.py b/assistants/project-assistant/assistant/tools/todo.py new file mode 100644 index 000000000..672b9fe2b --- /dev/null +++ b/assistants/project-assistant/assistant/tools/todo.py @@ -0,0 +1,61 @@ +import uuid +from enum import Enum + +from pydantic import BaseModel, Field + + +class ToDoItemStatus(Enum): + """ + Enum for the status of a to-do item. + """ + + PENDING = "pending" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + + +class ToDoItemPriority(Enum): + """ + Enum for the priority of a to-do item. + """ + + HIGH = "high" + MEDIUM = "medium" + LOW = "low" + + +class ToDoItem(BaseModel): + """ + A class to represent a single to-do item. + """ + + id: str = Field(default_factory=lambda: str(uuid.uuid4())) # Unique identifier + content: str + priority: str = "medium" # Default priority is 'medium' + status: str = "pending" # Default status is 'pending' + + +class ToDoList(BaseModel): + """ + A class to represent a to-do list. + """ + + items: list[ToDoItem] = Field(default_factory=list) + + def add(self, todo: ToDoItem) -> None: + """ + Add a new to-do item. + """ + self.items.append(todo) + + def remove(self, id: str) -> None: + """ + Remove a to-do item. + """ + self.items = [item for item in self.items if item.id != id] + + def list(self) -> list[ToDoItem]: + """ + List all to-do items. + """ + return self.items diff --git a/assistants/project-assistant/assistant/ui_tabs/__init__.py b/assistants/project-assistant/assistant/ui_tabs/__init__.py new file mode 100644 index 000000000..04dfb8671 --- /dev/null +++ b/assistants/project-assistant/assistant/ui_tabs/__init__.py @@ -0,0 +1,11 @@ +""" +Inspector modules for the Knowledge Transfer Assistant. Provide different views +of the knowledge transfer state in the workbench UI. +""" + +from .brief import BriefInspector +from .debug import DebugInspector +from .learning import LearningInspector +from .sharing import SharingInspector + +__all__ = ["BriefInspector", "DebugInspector", "LearningInspector", "SharingInspector"] diff --git a/assistants/project-assistant/assistant/ui_tabs/brief.py b/assistants/project-assistant/assistant/ui_tabs/brief.py new file mode 100644 index 000000000..027bd6654 --- /dev/null +++ b/assistants/project-assistant/assistant/ui_tabs/brief.py @@ -0,0 +1,121 @@ +""" +Brief inspector for knowledge transfer status and brief information. +""" + +from semantic_workbench_assistant.assistant_app import ( + AssistantConversationInspectorStateDataModel, + ConversationContext, +) + +from assistant.data import ConversationRole, Share +from assistant.domain.share_manager import ShareManager + +from .common import get_stage_label + +# Default instructional text to show when no brief has been created +DEFAULT_BRIEF_INSTRUCTION = "_This knowledge brief is displayed in the side panel of all of your team members' conversations, too. Before you share links to your team, ask your assistant to update the brief with whatever details you'd like here. What will help your teammates get off to a good start as they explore the knowledge you are sharing?_" # noqa: E501 + + +class BriefInspector: + """ + Inspector for knowledge transfer status and brief information. + + Shows role, stage, status message, and knowledge brief content. + """ + + display_name = "📋 Brief" + description = "Knowledge share overview" + state_id = "brief" + + def __init__(self, config_provider) -> None: + self.config_provider = config_provider + + async def is_enabled(self, context: ConversationContext) -> bool: + return True + + async def get(self, context: ConversationContext) -> AssistantConversationInspectorStateDataModel: + """Get brief and status information for display.""" + + share = await ShareManager.get_share(context) + + conversation_role = await ShareManager.get_conversation_role(context) + if conversation_role == ConversationRole.COORDINATOR: + markdown = await self._format_coordinator_brief(share) + else: + markdown = await self._format_team_brief(share) + + return AssistantConversationInspectorStateDataModel(data={"content": markdown}) + + async def _format_coordinator_brief( + self, + share: Share, + ) -> str: + """Format brief information for coordinator.""" + + lines: list[str] = [] + + # Stage + stage_label = get_stage_label(share, for_coordinator=True) + lines.append(f"**Stage:** {stage_label}") + + # Audience and takeaways + lines.append("## Audience") + lines.append(share.audience if share.audience else "_No audience defined._") + + if share.audience_takeaways: + lines.append("### Key Takeaways") + lines.append("") + for takeaway in share.audience_takeaways: + lines.append(f"- {takeaway}") + lines.append("") + + brief = share.brief + if brief and brief.title: + title = brief.title + lines.append(f"## {title}") + lines.append("") + else: + lines.append("## Knowledge Brief") + lines.append("") + + if brief and brief.content: + lines.append(brief.content) + lines.append("") + else: + lines.append(DEFAULT_BRIEF_INSTRUCTION) + lines.append("") + + return "\n".join(lines) + + async def _format_team_brief(self, share: Share) -> str: + """Format brief information for team members.""" + + lines: list[str] = [] + + # Stage + stage_label = get_stage_label(share, for_coordinator=False) + lines.append(f"**Stage:** {stage_label}") + + brief = share.brief + if brief: + title = brief.title + lines.append(f"## {title}") + lines.append("") + + if share.audience_takeaways: + lines.append("### Key Takeaways") + lines.append("") + for takeaway in share.audience_takeaways: + lines.append(f"- {takeaway}") + lines.append("") + + if brief and brief.content: + lines.append(brief.content) + lines.append("") + else: + lines.append("## Knowledge Brief") + lines.append("") + lines.append("_The coordinator is still setting up the knowledge brief. Check back soon!_") + lines.append("") + + return "\n".join(lines) diff --git a/assistants/project-assistant/assistant/ui_tabs/common.py b/assistants/project-assistant/assistant/ui_tabs/common.py new file mode 100644 index 000000000..c60c7b88f --- /dev/null +++ b/assistants/project-assistant/assistant/ui_tabs/common.py @@ -0,0 +1,91 @@ +""" +Common utilities for inspector modules. +""" + +from assistant.data import RequestPriority, RequestStatus, Share, TaskPriority, TaskStatus +from assistant.domain import TransferManager + + +def get_status_emoji(status: RequestStatus) -> str: + """Get emoji representation for request status.""" + status_emojis = { + RequestStatus.NEW: "🆕", # New + RequestStatus.ACKNOWLEDGED: "👀", # Acknowledged/Seen + RequestStatus.IN_PROGRESS: "⚡", # In Progress + RequestStatus.RESOLVED: "✅", # Resolved/Complete + RequestStatus.DEFERRED: "⏸️", # Deferred/Paused + } + return status_emojis.get(status, "❓") # Unknown status fallback + + +def task_priority_emoji(priority: TaskPriority) -> str: + """Get emoji representation for task priority.""" + priority_emojis = { + TaskPriority.LOW: "🔹", # Low priority - blue diamond + TaskPriority.MEDIUM: "🔶", # Medium priority - orange diamond + TaskPriority.HIGH: "🔴", # High priority - red circle + } + return priority_emojis.get(priority, "🔹") # Default to low priority emoji + + +def task_status_emoji(status: TaskStatus) -> str: + """Get emoji representation for task status.""" + status_emojis = { + TaskStatus.PENDING: "⏳", # Pending - hourglass + TaskStatus.IN_PROGRESS: "⚙️", # In Progress - gear + TaskStatus.COMPLETED: "✅", # Completed - check mark + TaskStatus.CANCELLED: "❌", # Cancelled - cross mark + } + return status_emojis.get(status, "❓") # Unknown status fallback + + +def get_priority_emoji(priority: RequestPriority) -> str: + """Get emoji representation for request priority.""" + priority_emojis = { + RequestPriority.LOW: "🔹", # Low priority - blue diamond + RequestPriority.MEDIUM: "🔶", # Medium priority - orange diamond + RequestPriority.HIGH: "🔴", # High priority - red circle + RequestPriority.CRITICAL: "⚠️", # Critical priority - warning sign + } + return priority_emojis.get(priority, "🔹") # Default to low priority emoji + + +def get_stage_label(share: Share, for_coordinator: bool = True) -> str: + """ + Get a human-readable stage label based on current share state. + + Args: + share: The knowledge share to get label for + for_coordinator: Whether to return coordinator-focused or team-focused labels + + Returns: + str: Stage label with emoji + """ + if for_coordinator: + # Coordinator perspective + if not share.audience: + return "🎯 Defining Audience" + # elif not share.knowledge_organized: + # return "📋 Organizing Knowledge" + elif not share.brief: + return "📝 Creating Brief" + elif share.is_intended_to_accomplish_outcomes and not share.learning_objectives: + return "📚 Adding Objectives" + elif not TransferManager.is_ready_for_transfer(share): + return "📋 Finalizing Setup" + elif share.is_intended_to_accomplish_outcomes and TransferManager._is_transfer_complete(share): + return "✅ Transfer Complete" + elif TransferManager.is_actively_sharing(share): + return "📤 Sharing in Progress" + else: + return "🚀 Ready for Transfer" + else: + # Team perspective + if not TransferManager.is_ready_for_transfer(share): + return "⏳ Knowledge Being Organized" + elif not share.is_intended_to_accomplish_outcomes: + return "🔍 Exploring Knowledge" + elif share.is_intended_to_accomplish_outcomes: + return "🎯 Active Learning" + else: + return "🎯 Active Learning" diff --git a/assistants/project-assistant/assistant/ui_tabs/debug.py b/assistants/project-assistant/assistant/ui_tabs/debug.py new file mode 100644 index 000000000..51ce23a66 --- /dev/null +++ b/assistants/project-assistant/assistant/ui_tabs/debug.py @@ -0,0 +1,114 @@ +""" +Debug inspector for internal assistant state and knowledge digest. +""" + +from semantic_workbench_assistant.assistant_app import ( + AssistantConversationInspectorStateDataModel, + ConversationContext, +) + +from assistant.data import ConversationRole, Share +from assistant.domain import KnowledgeDigestManager, ShareManager, TransferManager +from assistant.domain.conversation_preferences_manager import ConversationPreferencesManager +from assistant.domain.tasks_manager import TasksManager +from assistant.ui_tabs.common import task_priority_emoji, task_status_emoji + + +class DebugInspector: + """ + Inspector for debug information and internal assistant state. + + Shows the knowledge digest and other internal information maintained by the assistant. + """ + + display_name = "🐛 Debug" + description = "Internal assistant state and knowledge digest" + state_id = "debug" + + def __init__(self, config_provider) -> None: + self.config_provider = config_provider + + async def is_enabled(self, context: ConversationContext) -> bool: + return True + + async def get(self, context: ConversationContext) -> AssistantConversationInspectorStateDataModel: + """Get debug information for display.""" + + # Get share information + share = await ShareManager.get_share(context) + markdown = await self._format_debug_info(context, share) + return AssistantConversationInspectorStateDataModel(data={"content": markdown}) + + async def _format_debug_info(self, context: ConversationContext, share: Share) -> str: + """Format debug information including knowledge digest.""" + + lines: list[str] = [] + + lines.append("## Debug Information") + lines.append("_This panel shows internal information maintained by the assistant. This data is automatically") + lines.append("generated and updated by the assistant and is not directly editable by users._") + lines.append("") + + # Share metadata + share = await ShareManager.get_share(context) + lines.append("## Share Metadata") + lines.append(f"- **Share ID:** `{share.share_id}`") + lines.append(f"- **Created:** {share.created_at.strftime('%Y-%m-%d %H:%M:%S')}") + lines.append(f"- **Last Updated:** {share.updated_at.strftime('%Y-%m-%d %H:%M:%S')}") + lines.append(f"- **Team Conversations:** {len(share.team_conversations) if share.team_conversations else 0}") + lines.append(f"- **Learning Objectives:** {len(share.learning_objectives) if share.learning_objectives else 0}") + # lines.append(f"- **Knowledge Organized:** {share.knowledge_organized}") + lines.append(f"- **Ready for Transfer:** {TransferManager.is_ready_for_transfer(share)}") + lines.append(f"- **Actively Sharing:** {TransferManager.is_actively_sharing(share)}") + if share.coordinator_conversation_id: + lines.append(f"- **Conversation ID:** `{share.coordinator_conversation_id}`") + lines.append("") + + # Conversation metadata + lines.append("## Conversation Metadata") + role_type = await ShareManager.get_conversation_role(context) + role = "Coordinator" if role_type == ConversationRole.COORDINATOR else "Team Member" + lines.append(f"- **Role:** {role}") + style = await ConversationPreferencesManager.get_preferred_communication_style(context) + lines.append(f"- **Preferred Communication Style:** {style}") + + # Tasks + lines.append("## Assistant task list") + tasks = await TasksManager.get_tasks(context) + if tasks: + for task in tasks: + lines.append(f"- {task_status_emoji(task.status)} {task_priority_emoji(task.priority)} {task.content}") + lines.append("") + else: + lines.append("_No tasks recorded yet._") + + # knowledge digest + try: + digest = await KnowledgeDigestManager.get_knowledge_digest(context) + + lines.append("## Knowledge Digest") + lines.append("") + lines.append("The knowledge digest is an internal summary of the conversation that the assistant") + lines.append("maintains to help understand the context and key information being shared. It is") + lines.append("automatically updated as the conversation progresses.") + lines.append("") + + if digest and digest.content: + lines.append("### Current Digest Content") + lines.append("") + lines.append("```markdown") + lines.append(digest.content) + lines.append("```") + lines.append("") + else: + lines.append("_No knowledge digest has been generated yet. The assistant will create and update_") + lines.append("_this automatically as the conversation develops._") + lines.append("") + + except Exception as e: + lines.append("## Knowledge Digest") + lines.append("") + lines.append(f"**Error retrieving knowledge digest:** {e!s}") + lines.append("") + + return "\n".join(lines) diff --git a/assistants/project-assistant/assistant/ui_tabs/learning.py b/assistants/project-assistant/assistant/ui_tabs/learning.py new file mode 100644 index 000000000..a2c6ce64f --- /dev/null +++ b/assistants/project-assistant/assistant/ui_tabs/learning.py @@ -0,0 +1,158 @@ +""" +Learning inspector for learning objectives and progress tracking. +""" + +from typing import Any + +from semantic_workbench_assistant.assistant_app import ( + AssistantConversationInspectorStateDataModel, + ConversationContext, +) + +from assistant.data import ConversationRole +from assistant.domain import LearningObjectivesManager, ShareManager + + +class LearningInspector: + """ + Inspector for learning objectives and progress tracking. + + Shows learning objectives, outcomes, and completion progress. + """ + + display_name = "🎯 Learning" + description = "Learning objectives and progress tracking" + state_id = "objectives" + + def __init__(self, config_provider) -> None: + self.config_provider = config_provider + + async def is_enabled(self, context: ConversationContext) -> bool: + return True + + async def get(self, context: ConversationContext) -> AssistantConversationInspectorStateDataModel: + """Get learning objectives and progress information.""" + + conversation_role = await ShareManager.get_conversation_role(context) + + # Get share information + share_id = await ShareManager.get_share_id(context) + if not share_id: + return AssistantConversationInspectorStateDataModel( + data={"content": "No active knowledge package. Start a conversation to create one."} + ) + + share = await ShareManager.get_share(context) + + if conversation_role == ConversationRole.COORDINATOR: + markdown = await self._format_coordinator_objectives(share, context) + else: + markdown = await self._format_team_objectives(share, context) + + return AssistantConversationInspectorStateDataModel(data={"content": markdown}) + + async def _format_coordinator_objectives(self, share: Any, context: ConversationContext) -> str: + """Format learning objectives for coordinator.""" + + lines: list[str] = [] + + if not share or not share.learning_objectives: + lines.append("## Learning Objectives") + lines.append( + "_No learning objectives have been set up yet. When shared, the assistant will help your recipients explore the knowledge in a more open way, helping them discover the important aspects of the knowledge without specific objectives or outcomes. If you would like to have a more formal process, ask your assistant to help you create learning objectives and outcomes._" # noqa: E501 + ) + return "\n".join(lines) + + # Overall progress summary + total_outcomes = sum(len(obj.learning_outcomes) for obj in share.learning_objectives if obj.learning_outcomes) + if total_outcomes > 0 and share.team_conversations: + lines.append("## Team Progress") + for conv_id, team_conv in share.team_conversations.items(): + achieved, total = LearningObjectivesManager.get_completion_for_conversation(share, conv_id) + progress_pct = int(achieved / total * 100) if total > 0 else 0 + lines.append(f"- **{team_conv.redeemer_name}**: {achieved}/{total} outcomes ({progress_pct}%)") + + # Detailed objectives + lines.append("## Learning Objectives") + for objective in share.learning_objectives: + lines.append(f"### 🎯 {objective.name}") + lines.append(objective.description) + + if objective.learning_outcomes: + lines.append("#### Learning Outcomes") + for criterion in objective.learning_outcomes: + # Check if any team conversation has achieved this outcome + achieved_by_any = any( + LearningObjectivesManager.is_outcome_achieved_by_conversation(share, criterion.id, conv_id) + for conv_id in share.team_conversations + ) + status_emoji = "✅" if achieved_by_any else "⬜" + + # Show progress ratio for team completion + achieved_count = 0 + total_team_count = len(share.team_conversations) + + for conv_id in share.team_conversations: + if LearningObjectivesManager.is_outcome_achieved_by_conversation(share, criterion.id, conv_id): + achieved_count += 1 + + achievement_info = "" + if total_team_count > 0: + achievement_info = f" ({achieved_count}/{total_team_count})" + + lines.append(f"- {status_emoji} {criterion.description}{achievement_info}") + + return "\n".join(lines) + + async def _format_team_objectives(self, share: Any, context: ConversationContext) -> str: + """Format learning objectives for team members.""" + + lines: list[str] = [] + + if not share or not share.learning_objectives: + lines.append("## Learning Objectives") + lines.append( + "_The coordinator hasn't set up specific learning objectives for this shared knowledge. " + "Enjoy exploring at your own pace! " + "The assistant will guide you towards important information as you go._" + ) + return "\n".join(lines) + + lines.append("## Learning Objectives") + + # Show my personal progress + conversation_id = str(context.id) + achieved_outcomes, total_outcomes = LearningObjectivesManager.get_completion_for_conversation( + share, conversation_id + ) + progress_pct = int(achieved_outcomes / total_outcomes * 100) if total_outcomes > 0 else 0 + lines.append(f"**My Progress:** {achieved_outcomes}/{total_outcomes} outcomes achieved ({progress_pct}%)") + + for objective in share.learning_objectives: + lines.append(f"### 🎯 {objective.name}") + lines.append(objective.description) + + if objective.learning_outcomes: + lines.append("") + lines.append("#### Learning Outcomes") + for criterion in objective.learning_outcomes: + # Check if I've achieved this outcome + achieved_by_me = LearningObjectivesManager.is_outcome_achieved_by_conversation( + share, criterion.id, conversation_id + ) + status_emoji = "✅" if achieved_by_me else "⬜" + + completion_info = "" + if achieved_by_me: + # Find my achievement record + my_achievements = LearningObjectivesManager.get_achievements_for_conversation( + share, conversation_id + ) + for achievement in my_achievements: + if achievement.outcome_id == criterion.id and achievement.achieved: + completion_info = f" (achieved on {achievement.achieved_at.strftime('%Y-%m-%d')})" + break + + lines.append(f"- {status_emoji} {criterion.description}{completion_info}") + + return "\n".join(lines) diff --git a/assistants/project-assistant/assistant/ui_tabs/sharing.py b/assistants/project-assistant/assistant/ui_tabs/sharing.py new file mode 100644 index 000000000..33c9d0d53 --- /dev/null +++ b/assistants/project-assistant/assistant/ui_tabs/sharing.py @@ -0,0 +1,167 @@ +""" +Sharing inspector for information requests and responses. +""" + +from typing import Any + +from semantic_workbench_assistant.assistant_app import ( + AssistantConversationInspectorStateDataModel, + ConversationContext, +) + +from assistant.data import ConversationRole, RequestStatus +from assistant.domain import ( + InformationRequestManager, + LearningObjectivesManager, + ShareManager, +) + +from .common import get_priority_emoji, get_status_emoji + + +class SharingInspector: + """ + Inspector for information requests and responses. + + Shows pending and resolved information requests. + """ + + display_name = "🔗 Sharing" + description = "Sharing and information requests" + state_id = "requests" + + def __init__(self, config_provider) -> None: + self.config_provider = config_provider + + async def is_enabled(self, context: ConversationContext) -> bool: + return True + + async def get(self, context: ConversationContext) -> AssistantConversationInspectorStateDataModel: + """Get information requests for display.""" + + conversation_role = await ShareManager.get_conversation_role(context) + + # Get share information + share_id = await ShareManager.get_share_id(context) + if not share_id: + return AssistantConversationInspectorStateDataModel( + data={"content": "No active knowledge package. Start a conversation to create one."} + ) + + requests = await InformationRequestManager.get_information_requests(context) + + if conversation_role == ConversationRole.COORDINATOR: + markdown = await self._format_coordinator_requests(requests, context) + else: + markdown = await self._format_team_requests(requests, context) + + return AssistantConversationInspectorStateDataModel(data={"content": markdown}) + + async def _format_coordinator_requests(self, requests: list[Any], context: ConversationContext) -> str: + """Format sharing information and requests for coordinator.""" + + lines: list[str] = [] + + # Share URL section at the top + share = await ShareManager.get_share(context) + share_url = share.share_url if share else None + if share_url: + lines.append("## Share Link") + lines.append("") + lines.append("**Share this link with your team members:**") + lines.append(f"[Knowledge Transfer link]({share_url})") + lines.append("") + lines.append("The link never expires and can be used by multiple team members.") + lines.append("") + + # Filter requests by status + pending_requests = [req for req in requests if req.status != RequestStatus.RESOLVED] + resolved_requests = [req for req in requests if req.status == RequestStatus.RESOLVED] + + lines.append("## Information Requests") + lines.append("") + + if pending_requests: + lines.append(f"### Open Requests ({len(pending_requests)})") + lines.append("") + + for request in pending_requests: + priority_emoji = get_priority_emoji(request.priority) + status_emoji = get_status_emoji(request.status) + lines.append(f"{priority_emoji} **{request.title}** {status_emoji}") + lines.append(f"*From:* {request.conversation_id}") + lines.append(request.description) + lines.append("") + + if resolved_requests: + lines.append(f"### Resolved Requests ({len(resolved_requests)})") + lines.append("") + + for request in resolved_requests[:5]: # Show only first 5 resolved + lines.append(f"✅ **{request.title}**") + lines.append(f"*From:* {request.conversation_id}") + if hasattr(request, "resolution") and request.resolution: + lines.append(f"*Resolution:* {request.resolution}") + lines.append("") + + if not pending_requests and not resolved_requests: + lines.append("No information requests yet.") + lines.append("") + lines.append("_Team members can ask questions and create information requests that will appear here._") + + # Team summary + if share and share.team_conversations: + lines.append("## Team Summary") + lines.append(f"**Active team members:** {len(share.team_conversations)}") + lines.append("") + + for conv_id, team_conv in share.team_conversations.items(): + achieved, total = LearningObjectivesManager.get_completion_for_conversation(share, conv_id) + progress_pct = int(achieved / total * 100) if total > 0 else 0 + lines.append(f"- **{team_conv.redeemer_name}**: {achieved}/{total} outcomes ({progress_pct}%)") + lines.append(f" Joined: {team_conv.joined_at.strftime('%Y-%m-%d %H:%M')}") + lines.append(f" Last active: {team_conv.last_active_at.strftime('%Y-%m-%d %H:%M')}") + lines.append("") + + return "\n".join(lines) + + async def _format_team_requests(self, requests: list[Any], context: ConversationContext) -> str: + """Format sharing information and requests for team members.""" + + lines: list[str] = [] + + # Filter to my requests only + my_requests = [r for r in requests if r.conversation_id == str(context.id)] + + lines.append("## My Information Requests") + + if my_requests: + pending = [r for r in my_requests if r.status != "resolved"] + resolved = [r for r in my_requests if r.status == "resolved"] + + if pending: + lines.append(f"### Pending Requests ({len(pending)})") + lines.append("") + for request in pending: + priority_emoji = get_priority_emoji(request.priority) + status_emoji = get_status_emoji(request.status) + lines.append(f"{priority_emoji} **{request.title}** {status_emoji}") + lines.append(request.description) + lines.append("") + + if resolved: + lines.append(f"### Resolved Requests ({len(resolved)})") + lines.append("") + for request in resolved: + lines.append(f"✅ **{request.title}**") + if hasattr(request, "resolution") and request.resolution: + lines.append(f"*Resolution:* {request.resolution}") + lines.append("") + else: + lines.append("You haven't created any information requests yet.") + lines.append("") + lines.append( + "_Your assistant will help you create information requests to the knowledge coordinator if it is unable to answer your questions directly._" # noqa: E501 + ) + + return "\n".join(lines) diff --git a/assistants/project-assistant/assistant/utils.py b/assistants/project-assistant/assistant/utils.py index 4f92e30cf..008b88020 100644 --- a/assistants/project-assistant/assistant/utils.py +++ b/assistants/project-assistant/assistant/utils.py @@ -1,21 +1,21 @@ """ -Utility functions for the project assistant module. +Utility functions for the knowledge transfer assistant module. -This module provides common utility functions used across the project assistant +This module provides common utility functions used across the knowledge transfer assistant codebase, helping to reduce code duplication and maintain consistency. """ import pathlib -from typing import Optional, Tuple from semantic_workbench_assistant.assistant_app import ConversationContext -from .logging import logger +from assistant.errors import NoUserException +from assistant.string_utils import render DEFAULT_TEMPLATE_ID = "default" -def load_text_include(filename) -> str: +def load_text_include(filename: str, replacements: dict[str, str] = {}) -> str: """ Helper for loading an include from a text file. @@ -25,26 +25,15 @@ def load_text_include(filename) -> str: Returns: The content of the text file """ - # Get directory relative to this module directory = pathlib.Path(__file__).parent - - # Get the file path for the prompt file file_path = directory / "text_includes" / filename + text = file_path.read_text() + return render(text, replacements) - # Read the prompt from the file - return file_path.read_text() - - -async def get_current_user(context: ConversationContext) -> Tuple[Optional[str], Optional[str]]: - """ - Extract the current user ID and name from the conversation context. - Args: - context: The conversation context to extract user information from - - Returns: - A tuple containing (user_id, user_name), both may be None if no user is found - """ +async def get_current_user( + context: ConversationContext, +) -> tuple[str, str | None]: participants = await context.get_participants() user_id = None user_name = None @@ -55,37 +44,12 @@ async def get_current_user(context: ConversationContext) -> Tuple[Optional[str], user_name = participant.name break - return user_id, user_name - + if not user_id: + raise NoUserException -async def get_current_user_id(context: ConversationContext) -> Optional[str]: - """ - Extract just the current user ID from the conversation context. + return user_id, user_name - Args: - context: The conversation context to extract user information from - Returns: - The user ID, or None if no user is found - """ +async def get_current_user_id(context: ConversationContext) -> str: user_id, _ = await get_current_user(context) return user_id - - -async def require_current_user(context: ConversationContext, operation_name: str) -> Optional[str]: - """ - Extract the current user ID and log an error if none is found. - - Args: - context: The conversation context to extract user information from - operation_name: Name of the operation requiring a user, for error logging - - Returns: - The user ID, or None if no user is found (after logging an error) - """ - user_id = await get_current_user_id(context) - - if not user_id: - logger.error(f"Cannot {operation_name}: no user found in conversation") - - return user_id diff --git a/assistants/project-assistant/docs/ASSISTANT_LIBRARY_NOTES.md b/assistants/project-assistant/docs/ASSISTANT_LIBRARY_NOTES.md deleted file mode 100644 index 5fc670ba1..000000000 --- a/assistants/project-assistant/docs/ASSISTANT_LIBRARY_NOTES.md +++ /dev/null @@ -1,94 +0,0 @@ -# Semantic Workbench Assistant Library - -This document provides an overview of the Semantic Workbench Assistant library found in `/workspaces/semanticworkbench/libraries/python/semantic-workbench-assistant/semantic_workbench_assistant`. - -## Overview - -The semantic-workbench-assistant library provides a framework for building assistants that integrate with the Semantic Workbench platform. It handles communication protocols, state management, event handling, and provides an abstraction layer that simplifies assistant development. - -## Key Components - -### Core Classes - -- **AssistantApp**: The main entry point for creating assistants with event subscriptions. - - Configures service metadata, capabilities, and providers - - Initializes event handling system - - Creates the FastAPI application - -- **ConversationContext**: Interface for interacting with conversations - - Manages messages, files, and conversation state - - Provides methods for conversation operations - - Integrates with the Workbench client for API operations - -- **AssistantContext**: Holds information about the assistant identity - -### State Management - -- **File-based persistence**: - - `storage.py` provides `read_model()` and `write_model()` for serializing Pydantic models to files - - Models are stored as JSON in a configurable storage directory - - `storage_directory_for_context()` creates unique paths for each assistant/conversation - -- **Context objects**: - - `AssistantContext` and `ConversationContext` serve as state containers - - Conversation-specific operations are accessed through the context - -### Communication - -- **Event-driven architecture**: - - Assistants subscribe to conversation events (messages, participants, files) - - Asynchronous event processing queues decouple event reception from handling - - Event system notifies the assistant of changes to track state - -- **API Integration**: - - Uses a client to communicate with the Workbench service - - Provides methods for sending messages, managing files, and updating state - -## Usage for Project Assistant - -For the Project Assistant, the library provides: - -1. **File storage mechanisms** for persisting project state between sessions -2. **Context objects** for accessing conversation data and performing operations -3. **Event handling** for reacting to changes in conversations -4. **Cross-conversation capabilities** through API clients -5. **Abstraction layer** for Workbench service integration - -## Implementation Details - -### Storage Pattern - -```python -# Example of reading/writing models -from semantic_workbench_assistant.storage import read_model, write_model - -# Write a model to storage -def save_state(context, data): - path = storage_directory_for_context(context) / "project_data.json" - write_model(path, data) - -# Read a model from storage -def load_state(context, model_class): - path = storage_directory_for_context(context) / "project_data.json" - return read_model(path, model_class) -``` - -### Context Usage - -```python -# Example of context methods -async def process_message(context: ConversationContext, message): - # Send a response - await context.send_messages(NewConversationMessage( - content="Processing your request", - message_type=MessageType.chat - )) - - # Access files - files = await context.get_files() - - # Update conversation state - await context.send_conversation_state_event( - AssistantStateEvent(state_id="project_dashboard", event="updated", state=None) - ) -``` \ No newline at end of file diff --git a/assistants/project-assistant/docs/DESIGN.md b/assistants/project-assistant/docs/DESIGN.md deleted file mode 100644 index 9ad66bffd..000000000 --- a/assistants/project-assistant/docs/DESIGN.md +++ /dev/null @@ -1,259 +0,0 @@ -# Project Assistant Implementation Plan - -## Overview - -The Project Assistant is designed as a dual-role context transfer system that facilitates knowledge transfer between different conversations in the Semantic Workbench. It provides a structured way for Coordinators to create project information and Team members to access it, with bidirectional updates and communication. - -## System Design - -### Configuration Templates - -The Project Assistant supports two different configuration templates within a unified codebase. The templates modify the behavior of the assistants considerably, so we basically have two different assistants with this single assistant codebase. - -1. **Default Template (Project Assistant)**: - - Full project management capabilities with tracking features - - Includes goals, success criteria, and project state monitoring - - Tracks progress of project tasks and completion status - - Uses "Project Assistant" branding and terminology - - Provides comprehensive project dashboard with status tracking - - Focuses on project management workflow with sequential stages - -2. **Context Transfer Template (Context Transfer Assistant)**: - - Simplified knowledge sharing without project tracking features - - Designed for knowledge transfer without formal project structure - - No goals or success criteria tracking - - Uses "Context Transfer Assistant" branding and terminology - - Maintains core information request capabilities without project stages - - Different welcome messages and UI labels appropriate for knowledge sharing - -The system automatically adapts its behavior, prompts, and UI based on which template is selected during assistant instantiation, while maintaining a unified codebase and consistent architecture. - -Some features remain in both assistant configurations: - -- Both assistants maintain a "whiteboard" internally to build up context of the project (the "project" for the Context Transfer Assistant is to transfer the context well, while for the Project Assistant it is a project in a more formal sense). The whiteboard is updated by the assistant each time they reply to the user. The whiteboard is distinct from the project information dashboard which is a collaboration between the user and the assistant. -- Both assistants syncronize project information and files between coordinators and team members. -- Both assistants facilitate the creation of information requests by team members, and the answering of information requests by the coordinators. -- Both assistants can speak conversationally and run their own sets of tools. - -### Conversation Structure - -The Project Assistant (in both configurations) manages three distinct types of conversations: - -1. **Coordinator Conversation**: - - Created when a user first interacts with the Project Assistant - - Acts as the personal workspace for the project owner/coordinator - - Contains private communication between the coordinator and the assistant - - Stores the link to the shareable team conversation - - Used for high-level project management and planning - -2. **Shareable Team Conversation**: - - Automatically created when a coordinator starts a new project - - Never directly used by any user - serves as a template only - - Has a share URL associated with it - - When team members click the share link, they get a copy of this conversation - - Contains project-specific setup and metadata - -3. **Team Conversation(s)**: - - Created when a team member redeems the share URL - - Each team member gets their own personal conversation - - All team conversations are linked to the same project - - Used for team members to work on the project, make information requests, etc. - - Automatically set up with the team member role - -### Conversation Roles - -Within each configuration template, the Project Assistant supports two distinct conversation roles: - -1. **Coordinator Role**: - - Knowledge Base Development: Collection and organization of project-critical information - - Information Request Resolution: Coordinator resolves information requests from team members - - **In Default Template**: - - Project Brief Creation with goals and success criteria - - Project Preparation with staged milestones - - "Ready for Working" milestone management - - **In Context Transfer Template**: - - Knowledge organization without formal project stages - - Focus on information structuring without tracking progress - -2. **Team Member Role**: - - Information Access: Team members interact with the shared Knowledge Base - - Request Management: Team members create and delete information requests as needed - - **In Default Template**: - - Progress Tracking with criteria completion - - Dashboard updates with completion status - - Support for "Project Completion" milestone - - **In Context Transfer Template**: - - Knowledge exploration without progress tracking - - Information requests without formal project stages - -For both configuration templates, the system supports an iterative and asynchronous workflow where the team members' operations and the coordinator's support activities can occur concurrently. The default template provides a comprehensive project dashboard with status tracking, while the context transfer template focuses on knowledge exchange without formal project stages. - -### Key Architectural Features - -1. **Simplified Invitation System**: - - Uses Semantic Workbench features to clone new team conversations from shareable conversations. The coordinator gives the team members redeemable share links out-of-band. - -2. **Comprehensive LLM Context**: - - Project data (brief, info, whiteboard, requests) embedded directly in prompts - - Role-specific formatting to highlight relevant information - - Dynamic listing of information requests with proper ID formatting - - Intelligent truncation to manage context length - - Improves response quality by reducing the need for tool calls - -3. **Robust Information Request Management**: - - Complete lifecycle from creation to deletion - - Enhanced UUID handling with multiple matching strategies - - Conversation-based ownership controls - - Role-appropriate visibility of requests - - Proper notification for all parties - -4. **Coordinator Conversation Sharing**: - - Selective Coordinator conversation message sharing with team members - - Storage in a centralized JSON file for all team members to access - - Automatic capture of both user and assistant messages - - Metadata preservation including sender name and timestamp - - Limited to recent messages (last 50) to prevent excessive storage - - Team members can view Coordinator discussions for better context awareness - -## Data - -The Project Assistant manages several key entities that work together to provide a cohesive experience: - -1. **Project Brief**: A clear, concise statement of the project, including goals, success criteria, and high-level context necessary for the Team to start. Owned by Coordinator, with individual success criteria that can be marked complete by Team members. - -2. **Project Whiteboard**: A dynamic, automatically updated knowledge repository that captures key information from conversations. The whiteboard is continuously updated as the coordinator interacts with the assistant, extracting and organizing essential project context. It's formatted in Markdown and made available to all team members without requiring manual maintenance. - -3. **Project Info**: Core metadata and state information about the project, including its unique ID, name, current lifecycle state, conversation IDs, share URL, and status messages. This model serves as the central reference point for project identification and collaboration settings. It replaced the previous dashboard entity to eliminate duplication and provide cleaner separation between data and UI. - -4. **Information Requests**: A concise, prioritized list of Team needs—specifically unresolved blockers, missing information, or required resources—logged for Coordinator review and resolution. Created and deletable by Team members, resolved by Coordinator, with robust UUID-based identification. - -5. **Project Log**: A chronological record of all actions and interactions during the project, including updates to the project whiteboard, creation and deletion of information requests, and progress reports from the team. Maintained by the system, visible to both coordinator and team members. - -6. **Coordinator Conversation Storage**: A selective representation of key Coordinator conversation messages made accessible to Team members for context. Includes both user and assistant messages with metadata, limited to the most recent 50 messages to prevent excessive storage growth, with proper attribution of message sources. - -The State Inspector UI component (visible tab in the Semantic Workbench) dynamically composes information from these entities to present a unified view, rather than relying on a single "dashboard" entity. This decoupling of data from UI allows for more flexible presentation and eliminates redundancy. - -## Storage Architecture - -The Project Assistant leverages the Semantic Workbench Assistant library's storage capabilities to maintain project state and artifacts. The storage architecture is structured as follows: - -``` -projects/ -├── project_id_1/ -│ ├── linked_conversations/ # Directory tracking all linked conversations -│ │ ├── conversation_id_1 # Empty file - just presence indicates linkage -│ │ ├── conversation_id_2 # Empty file for another linked conversation -│ │ └── ... # One file per linked conversation -│ ├── requests/ # Information requests directory -│ │ ├── request_id_1.json # Individual request files -│ │ └── request_id_2.json # Each with a unique UUID -│ ├── project.json # Core project information -│ ├── brief.json # Brief data -│ ├── whiteboard.json # Automatically updated knowledge content -│ ├── log.json # Chronological event log -│ └── coordinator_conversation.json # Recent coordinator messages for team access -└── project_id_2/ - └── ... -``` - -Additionally, conversation-specific data is stored in the assistant library's context-specific storage. This provides the mechanism for the assistant to know which project it is a part of: - -``` -.data/assistants/{assistant_id}/conversations/{conversation_id}/ -├── project_role.json # Role of this conversation (coordinator or team) -└── project_association.json # Project this conversation is associated with -``` - -Key implementation details: - -- Using the assistant library's `storage_directory_for_context()` to generate unique storage paths -- Storing Pydantic models via the library's `read_model()` and `write_model()` functions -- Each project gets a unique folder containing all shared project data -- Conversation roles and project associations tracked in conversation-specific storage -- Linked conversations tracked with empty files in a special directory -- Information requests stored as individual files with UUID-based filenames -- Auto-updating whiteboard maintained with AI-processed conversation content -- Coordinator conversation messages stored centrally with a maximum of 50 recent messages -- Project log maintained as a single growing JSON file with chronological entries -- Clean separation between conversation-specific and shared project data - -## Role-Based Behavior - -The project assistant provides a customized experience based on whether the user is in Coordinator or Team mode: - -### Coordinator Role - -- Creates and updates the Project Brief with goals and success criteria -- Contributes to the auto-updating Project Whiteboard through conversations -- Shares a unique project URL with team members for easy access -- Receives and resolves Information Requests from team members -- Sees all active requests from all team members with their priorities -- Controls milestone transitions (in default template) -- Receives notifications when team members delete their requests -- Gets comprehensive project data directly in the LLM prompt context - -### Team Member Role - -- Works with the Project Whiteboard that's automatically updated based on coordinator conversations -- Creates Information Requests when encountering information gaps or blockers -- Deletes Information Requests that are no longer needed -- Joins projects by redeeming the share URL provided by the coordinator -- Views requests from other team members in read-only mode -- Reports on progress and findings -- Marks individual success criteria as completed (in default template) -- Views recent coordinator conversations for additional context -- Gets comprehensive project data directly in the LLM prompt context - -## Data Models - -Five key entity types provide the foundation for project communication: - -1. **Project Brief**: - - Project name and description - - Goals with priority levels - - Success criteria with completion tracking - - Individual criterion completion with timestamp and attribution - - Version tracking for modifications - -2. **Project Whiteboard**: - - Dynamically generated and auto-updated content - - AI-powered synthesis of conversation content - - Simplified Markdown formatting for readability - - Version tracking with timestamps - - Automatic organization of key information - - Content truncation to maintain manageable size (limited to ~2000 tokens) - - Updated after each assistant message in coordinator conversations - -3. **Project Info**: - - Unique project identifier and name - - Current project state tracking (planning, ready_for_working, in_progress, completed, aborted) - - Coordinator and team conversation IDs - - Shareable invitation URL for team members - - Status messages and custom project notes - - Creation and update timestamps - - Serves as the central reference for project metadata - - Replaced previous dashboard entity to decouple data from UI - -4. **Information Requests**: - - Prioritized information needs (low, medium, high, critical) - - Status tracking (new, acknowledged, in_progress, resolved, deferred) - - Complete request lifecycle (creation, deletion) - - UUID-based identification with flexible matching - - Resolution information and update history - - Conversation-based ownership controls - -5. **Project Log**: - - Chronological record of all events - - Rich categorization system with specialized entry types - - Attribution of actions to specific users - - Metadata for event context - - Events for milestone transitions, request handling, and whiteboard updates - - Request deletion tracking - - Full history of all project activities - -6. **Coordinator Conversation Storage**: - - Shared access to coordinator conversation for team members - - Content and sender metadata preservation - - Limited message history (most recent 50 messages) - - Automatic pruning of older messages - - Includes both user and assistant messages diff --git a/assistants/project-assistant/docs/JTBD.md b/assistants/project-assistant/docs/JTBD.md new file mode 100644 index 000000000..5ca32d5e4 --- /dev/null +++ b/assistants/project-assistant/docs/JTBD.md @@ -0,0 +1,286 @@ +# Knowledge Transfer User Jobs and Outcomes + +## 💰 Job 1: Offload Knowledge + +“Get what's in my head (or docs) into other people.” +“Get what's in my head (or docs) into a system to share.” + +### 🙋 Target User: Producer + +Knowledge workers who use AI assistants to support complex cognitive tasks. This includes roles like product managers, researchers, technical leads, consultants, and operations strategists who frequently need to: + +* Delegate operational or repetitive work +* Preserve or transfer internal knowledge + +These users are technically literate and open to using AI, but often encounter friction, ambiguity, or lack of trust when doing so. + +### Examples + +* Brian fleshing out his ideas and gathering ideas and developing them. +* Sometimes it's important to know HOW someone else wants us to think about it. + +### 🧭 Offload Knowledge Outcomes by Job Step + +#### 1. Identify What to Offload + +* Minimize time to determine which knowledge is valuable to preserve +* Maximize ability to identify redundant information that you don't need to offload again. +* Minimize effort to identify knowledge that is at risk of being lost. Users are rarely prompted or supported in recognizing what’s ephemeral, especially tacit or role-specific knowledge. +* Maximize clarity on what knowledge others will want or will need in the future. Predicting downstream needs is difficult without clear collaboration or communication loops. + +#### 2. Extract Knowledge + +* Maximize completeness of the captured knowledge. AI often captures surface-level information, missing nuance, assumptions, or rationale. +* Minimize risk of omitting tacit or hard-to-articulate information. Tacit knowledge is inherently hard to extract, especially if not prompted well. Tools don't scaffold reflection. +* Minimize effort required to capture contextual details. Context is scattered across tools, meetings, and tacit knowledge — it’s rarely captured systematically. +* Minimize time required to transfer knowledge from a person or doc. + +#### 3. Structure and Store + +* Maximize clarity of how preserved knowledge is organized. +* Maximize consistency in labeling or tagging knowledge. +* Minimize time to structure knowledge into a consistent and understandable format +* Maximize ability to associate related knowledge items + +#### 4. Validate and Refine + +* Maximize ability to identify gaps. +* Maximize ability to identify outdated information. +* Maximize confidence that preserved knowledge reflects current thinking. +* Minimize effort to review knowledge for accuracy and clarity +* Minimize effort to review the knowledge organization for accuracy and clarity (e.g., are the most important things captured in reasonable ways) + +#### 5. Retrieve and Reuse + +* Maximize trust that retrieved knowledge is accurate and complete +* Minimize duplication of effort due to inaccessible or unknown knowledge. +* Minimize time to locate relevant preserved knowledge +* Maximize ability to reuse preserved knowledge in different contexts + +#### 6. Hand-off Knowledge + +* Minimize friction in handing off knowledge to consumers. +* Maximize consumer adoption (they are busy, want a certain tone, etc.; they are often caught "off guard" with a message "out of the blue" asking them to spend time on some knowledge. The ask needs to be clear and give them assurance that they can accomplish it productively). + +#### 7. Support knowledge consumers + +* Minimize time spent supporting followup questions from consumers. +* Minimize time spent answering question repeatedly. + +--- + +## 💰 Job 2: Learn Knowledge + +“Go from unfamiliar to well-informed, without needing a human tutor.” +"Know what someone else knows." + +### 🙋 Target User: Learner + +Knowledge workers who use AI assistants to support complex cognitive tasks. This includes roles like product managers, researchers, technical leads, consultants, and operations strategists who frequently need to ramp up quickly in new domains. + +These users are technically literate and open to using AI, but often encounter friction, ambiguity, or lack of trust when doing so. + +### 🧭 Learn Knowledge Outcomes by Job Map Step + +#### 1. Define the Scope + +* Minimize time it takes to know where to start. +* Minimize time to identify the core topics within the subject. +* Minimize risk of including out-of-scope material +* Maximize clarity on the boundaries of the subject +* Minimize risk of missing prerequisite concepts +* Maximize relevancy to my own context. "What of this is worth using?" + +#### 2. Find Learning Resources + +* Minimize time to locate credible, comprehensive resources. "The people who have the information I need aren't available." +* Maximize coverage of essential topics across selected materials +* Minimize effort to organize resources for access and review +* Minimize risk of relying on low-quality or outdated information + +#### 3. Build Understanding + +* Minimize time to grasp each new concept +* Maximize ability to explain concepts in one’s own words +* Maximize clarity of relationships between subtopics +* Maximize retention of key ideas over time + +#### 4. Validate Understanding + +* Minimize time to detect gaps or misconceptions. “I’m not sure if I’ve learned enough or covered everything important.” +* Maximize accuracy of self-assessment. "Did I get get it all? Am I done?" +* Maximize ability to apply knowledge in practical contexts. "I think I get it... oh... nope." +* Minimize risk of forgetting critical information. “I forget what I learned before I get to use it.” + +--- + +## 💰 Job 3: Explore a knowledge space + +“Gain clarity and insights by exploring a defined knowledge space.” +"Understand what's in someone else's head." + +### 🙋 Target User: Explorer + +Knowledge workers who use AI assistants to support complex cognitive tasks. This includes roles like product managers, researchers, technical leads, consultants, and operations strategists who frequently need to explore large bodies of information with confidence. + +These users are technically literate and open to using AI, but often encounter friction, ambiguity, or lack of trust when doing so. + +### Examples of Explore Job + +* With Cortex, people wanted the ideas from Brian to see how they would impact them + +### 🧭 Outcomes by Job Map Step + +#### 1. Define the knowledge area to explore + +* Increase clarity on the boundaries of the topic +* Decrease ambiguity in scoping unfamiliar domains. “I’m unsure if I’m exploring the right domain or if my scope is too broad or narrow.” +* Increase confidence that the area aligns with the problem being solved. “It’s mentally taxing to judge whether content is actually relevant to my goals.” + +#### 2. Identify sources and entry points + +* Increase speed of locating credible, relevant sources +* Improve understanding of the structure of the knowledge corpus +* Decrease effort to evaluate trustworthiness of sources. “It takes too much time and effort to figure out which sources I can trust.” +* Increase discoverability of expert-recommended or AI-curated entry points. “It’s hard to know where to start or what path to follow.” +* Increase confidence that the sources reflect current thinking or trends + +#### 3. Formulate exploration questions or goals + +* Decrease time to generate meaningful questions. “It’s hard to know what questions to ask or how to phrase them meaningfully.” +* Increase clarity of what to ask or look for next. +* Improve ability to map questions to available information types +* Decrease mental load in articulating precise, actionable questions + +#### 4. Assess relevance of information + +* Decrease effort to filter out irrelevant material. “There’s too much low-value information—makes it hard to find insights.” +* Increase precision of information aligned with questions +* Increase recall of information aligned with questions +* Increase speed of determining whether a piece of information answers a question +* Decrease cognitive effort in resolving conflicting information. “I get conflicting answers and don’t know how to resolve or interpret them.” +* Improve consistency in how relevance is judged across sources + +#### 5. Navigate or query the corpus + +* Increase efficiency of traversing the material +* Decrease time spent retracing steps or revisiting already-covered ground +* Increase responsiveness of the AI to follow-up questions or pivots +* Improve transparency in how paths through the information are constructed. “The AI doesn’t make it clear why it’s showing certain information or how it's reasoning.” + +#### 6. Supplement the corpus + +* Decrease effort in getting extra-corpus info. "I don't see anything here about X, so I have to have a meeting with Brian." + +#### 7. Capture discoveries and insights + +* Improve ability to retain and retrieve key findings. “I can’t easily remember or retrieve what I’ve already discovered.” +* Decrease effort required to synthesize takeaways into shareable form. “It’s hard to convert my findings into something I can share with others.” +* Increase support for connecting findings across different documents or contexts. +* Improve ability to separate raw data from insights or interpretations. “Even after reading, I struggle to summarize or synthesize key takeaways.” + +#### 8. Adjust direction based on findings + +* Decrease lag between learning something new and pivoting focus +* Increase ability to track why and when direction changed. “I lose track of how my thinking evolved or why I changed direction.” +* Decrease friction in iteratively refining exploration goals +* Improve visibility into emerging patterns or subtopics + +#### 9. Conclude with a sense of completeness + +* Increase confidence that key areas were explored. “I don’t know what’s missing or what I didn’t get to.” “There’s no signal to tell me whether I’ve covered what matters.” +* Increase clarity on what was *not* explored and why +* Decrease uncertainty about whether sufficient depth was achieved. “I’m not confident that I’ve covered the important areas or reached sufficient depth.” +* Improve ability to summarize progress and open questions for handoff or future work + +## Greatest Unmet Needs + +1. Explorer/Learner: Maximize trust that retrieved knowledge is accurate and complete +2. Producer: Minimize effort required to capture contextual details +3. Learner: Minimize time to detect gaps or misconceptions +4. Producer: Minimize risk of omitting tacit or hard-to-articulate information +5. Explorer: Increase confidence that the area aligns with the problem being solved +6. Learner/Explorer: Minimize time to locate credible, comprehensive resources +7. Producer: Minimize time to determine which knowledge is valuable to preserve +8. Producer: Maximize clarity on what knowledge others will want or will need in the future +9. Explorer: Improve transparency in how paths through the information are constructed +10. Explorer/Learner: Improve ability to separate raw data from insights or interpretations + +## 🔧 Recommended Feature Additions + +### 🏗️ For Producers + +(helping them offload rich, complete, reusable knowledge) + +✅ 1. Contextual Capture Prompts +→ Prompt producers for details like rationale, audience, assumptions, so tacit knowledge isn’t lost. +(Unmet needs: capturing context, tacit knowledge) + +✅ 2. Tacit Knowledge Extraction Mode +→ Activate a deeper reflection mode with targeted questions to surface hidden expertise. +(Unmet needs: omitting tacit info, future needs clarity) + +✅ ~~3. Guided Scoping for Producers +→ Early on, help producers decide *what* to offload by suggesting domains or themes. +(Unmet need: determining valuable knowledge)~~ + +✅ 4. Future-Use Persona Prompting +→ Let producers tag likely consumer types (e.g., new hires, partners), so the assistant extracts relevant info for *their* needs. +(Unmet need: clarity on future knowledge use) + +✅ 5. Auto-Gap Detection & Feedback +→ After initial capture, show missing pieces based on templates or common gaps. +(Unmet need: detecting knowledge gaps) + +### 🧭 For Explorers + +(helping them navigate and extract insights confidently) + +✅ 6. Problem Alignment Check-in +→ Before exploring, assistant confirms the explorer’s goal and recalibrates if needed. +(Unmet need: aligning area to problem) + +✅ 7. Path Transparency Overlay +→ Show a “breadcrumb” of how the assistant arrived at an answer — which sources, notes, or logic it used. +(Unmet need: transparency in reasoning) + +### 📚 For Learners + +(helping them ramp up faster and more confidently) + +✅ 8. Corpus Quality Report +→ Provide a snapshot upfront: what’s in the corpus, what’s missing, and how current it is. +(Unmet need: locating credible resources, setting scope) + +✅ 9. Auto-Synthesis Layer with Raw/Insight Split +→ Give both raw excerpts and synthesized takeaways in responses or briefs. +(Unmet need: separating raw data from interpretation) + +✅ 10. Confidence Scoring on Retrieved Answers +→ Attach confidence levels and explain why the assistant is sure (or unsure) about each answer. +(Unmet need: trust in answer accuracy) + +## Paul's take + +### Features for Producer assistance + +* Make the producer assistant more analytical. Interrogate for details like rationale, audience, assumptions, so tacit knowledge isn’t lost. +* Have the producer assistant Activate a deeper reflection mode with targeted questions to surface hidden expertise. +* Audience definition: Let producers tag likely consumer types (e.g., new hires, partners), so the assistant extracts relevant info for *their* needs. +* Make knowledge consumption modal: Learner or Explorer. If learner, determine what items really must be part of the curriculum (what the learner should know). +* Knowledge gap detection: After initial capture, show missing pieces based on templates or common gaps. + +### Features for Explorer assistance + +* Upfront snapshot: Explain to explorers: what’s in the corpus, what’s missing, and how current it is. +* Exploration goals: Before exploring, assistant confirms the explorer’s goal and recalibrates if needed. + +### Features for Learner assistance + +* Curriculum intro: Explain to learners what they will be learning (and what they will not be learning). +* Keep track of what the learner has been exposed to or not in the curriculum. +* Quiz the learner on the curriculum to ensure knowledge transfer. + +### Features for both Learners and Explorer assistance + +* Excerpts: Give both raw excerpts and synthesized takeaways in responses or briefs. diff --git a/assistants/project-assistant/docs/WORKBENCH_NOTES.md b/assistants/project-assistant/docs/WORKBENCH_NOTES.md deleted file mode 100644 index 81e6ba24e..000000000 --- a/assistants/project-assistant/docs/WORKBENCH_NOTES.md +++ /dev/null @@ -1,474 +0,0 @@ -# Semantic Workbench State Management - -This document provides an overview of the state management facilities available in the Semantic Workbench ecosystem that can be leveraged by assistants. - -## Architectural Overview - -```mermaid -graph TD - subgraph "Workbench Service" - DB[(SQL Database)] - FileStore[(File Storage)] - API[API Endpoints] - Events[Event System] - end - - subgraph "Assistant Instance" - AState[Assistant State API] - AStorage[Storage Directory] - EventHandler[Event Handler] - end - - subgraph "Conversation" - Messages[Messages] - Metadata[Message Metadata] - ConvFiles[Conversation Files] - end - - API <--> AState - API <--> Messages - API <--> ConvFiles - API <--> Metadata - FileStore <--> ConvFiles - DB <--> Messages - DB <--> Metadata - Events ---> EventHandler - EventHandler ---> AState - AState ---> AStorage - - classDef persistent fill:#b7e1cd,stroke:#82c3a6 - classDef transient fill:#f8cecc,stroke:#b85450 - classDef storage fill:#d5e8d4,stroke:#82b366 - - class DB,FileStore,AStorage persistent - class Events transient - class ConvFiles,AState storage -``` - -## State Storage Locations - -```mermaid -graph LR - subgraph "Persistent Storage" - DB[(SQL Database<br>PostgreSQL/SQLite)] - FileStore[(File Storage<br>Binary Files)] - LocalFS[(Local Filesystem<br>JSON Files)] - end - - subgraph "State Types" - Messages[Messages] - MsgMeta[Message Metadata] - ConvFiles[Conversation Files] - AssistState[Assistant State API] - PrivateState[Private Assistant Data] - end - - Messages --> DB - MsgMeta --> DB - ConvFiles --> FileStore - AssistState --> DB - PrivateState --> LocalFS - - classDef db fill:#dae8fc,stroke:#6c8ebf - classDef file fill:#d5e8d4,stroke:#82b366 - classDef local fill:#ffe6cc,stroke:#d79b00 - - class DB db - class FileStore file - class LocalFS local -``` - -## Conversation-level State Management - -### Message Metadata - -- **Key Mechanism**: Each message can include arbitrary metadata as JSON -- **Storage**: Persisted in the database with the message -- **Special Fields**: - - `attribution`: Source information displayed after the sender - - `href`: Makes message a hyperlink - - `debug`: Debug information accessible through UI inspection - - `footer_items`: Additional information displayed in the message footer - - `tool_calls`: Structured representation of tool invocations - - `tool_result`: Results from tool executions -- **Usage**: Primary method for assistants to store structured data -- **Limitations**: Cannot be used to transfer large amounts of data (practical limit around 100KB) - -### Files/Attachments - -- **Model**: Files are represented as `File` and `FileVersion` entities in the database -- **Storage**: Files are stored in a filesystem-based storage system managed by the Workbench service -- **Versioning**: - - Built-in version tracking for files with the same name - - All files with the same name in a conversation are treated as versions of the same file - - No way for users to explicitly designate a file with the same name as a new file rather than a new version -- **Access**: - - Files are visible in the conversation UI for users - - Files can be attached to messages via the `filenames` property - - Files can be uploaded, downloaded, and deleted through the UI -- **Permissions**: Files inherit conversation permissions -- **Limitations**: - - No direct UI for viewing or managing different versions of a file - - No way for users to specify storage at the assistant level versus conversation level - -### Conversation Sharing - -- **Usage**: Enables controlled access to conversation content -- **Details**: See SHARING.md for comprehensive details on the sharing mechanism - -### Events System - -- **Purpose**: Propagates changes to all conversation participants -- **Model**: `ConversationEvent` defines various event types -- **Types**: Message created/deleted, participant changes, etc. -- **Delivery**: Events delivered through Server-Sent Events (SSE) -- **Storage**: Events are not stored persistently - they are transient objects -- **Connection Requirements**: Recipients must be actively connected to receive events -- **Durability**: No event replay mechanism - events missed during disconnection are lost permanently -- **Limitations**: Cannot be relied upon for critical state synchronization due to potential message loss - -```mermaid -sequenceDiagram - participant User as User - participant Assistant as Assistant - participant API as Workbench API - participant Events as Event System - participant DB as Database - - Note over User,DB: Both connected - - User->>API: Send message - API->>DB: Store message - API->>Events: Generate message_created event - Events->>User: Notify via SSE - Events->>Assistant: Notify via SSE - - Note over User,DB: Assistant disconnects - - User->>API: Edit message - API->>DB: Update message - API->>Events: Generate message_updated event - Events->>User: Notify via SSE - Events--xAssistant: Event lost (disconnected) - - Note over User,DB: Assistant reconnects - - Assistant->>API: Get conversation - API->>DB: Query messages - API->>Assistant: Return current state - - Note over User,DB: No automatic notification<br/>about missed events -``` - -## Assistant-level State Management - -### Assistant State API - -- **Type**: Server-side state managed by the Workbench service -- **Model**: Assistants maintain state via `StateResponseModel` in the database -- **Structure**: Structured data represented as JSON schema -- **UI Integration**: UI schema maintained for display formatting in Workbench UI -- **Description**: State descriptions provide user-facing information -- **Persistence**: State persisted in the central SQL database (PostgreSQL/SQLite) -- **Access**: Accessible via REST API endpoints in the Workbench service -- **Visibility**: Can be exposed to users through UI integrations - -### Assistant Storage Directory - -- **Type**: Local filesystem storage specific to each assistant instance -- **Purpose**: Private storage for assistant-specific data -- **Location**: Typically `.data/assistants/[assistant-id]` directory -- **Implementation**: Created and managed by `storage.py` in semantic-workbench-assistant -- **Content**: Pydantic models serialized to JSON files via `write_model` function -- **Visibility**: Not exposed to users through the UI -- **Usage**: Appropriate for: - - Private cross-conversation data - - Assistant-specific configuration - - Cached or derived data - - Data that doesn't need UI visibility - -### Configuration - -- **Storage**: Assistant configuration stored in metadata -- **Scope**: Configuration specific to each assistant instance -- **Access**: Available through assistant service APIs - -## Cross-conversation Communication - -### Cross-Conversation Access - -- Allows assistants to interact with multiple conversations simultaneously -- Requires proper permissions and active participant records -- Managed through the conversation sharing system -- See SHARING.md for comprehensive details on: - - Permission models and enforcement - - User vs. assistant access patterns - - Conversation sharing mechanisms - - Principal and participant relationships - -### File Transfer - -- **Mechanism**: Files must be explicitly copied between conversations -- **Process**: - 1. Read file content from source conversation - 2. Create new file with same name in target conversation - 3. This creates a completely separate file, not linked to the original -- **Versioning**: Version history doesn't transfer between conversations - -```mermaid -sequenceDiagram - participant ConvA as Conversation A - participant API as Workbench API - participant ConvB as Conversation B - participant FSA as File Storage A - participant FSB as File Storage B - - Note over ConvA,FSB: File Transfer Process - - ConvA->>API: Request file content<br/>(conversation_id_A, filename) - API->>FSA: Retrieve file content - FSA->>API: Return file content - API->>ConvA: File content - - ConvA->>API: Upload to conversation B<br/>(conversation_id_B, filename, content) - API->>FSB: Store file content - API->>ConvB: Create file record - - Note over ConvA,FSB: Result: Two independent files with<br/>same name but separate version history -``` - -### Participant Model - -- **Types**: User participants and assistant participants -- **Status**: Online/offline status tracking -- **Permissions**: Read/write access controls -- **Events**: Participant join/leave events - -## File Storage Architecture - -### Storage System - -- **Physical Storage**: Files stored in filesystem managed by Workbench service -- **Reference System**: Messages and conversations reference files by name -- **Uniqueness**: Files are uniquely identified by the combination of conversation ID and filename -- **UI Integration**: Only conversation-level files are shown in the UI - -```mermaid -graph TD - subgraph "File Management System" - FileDB[(File Database)] - FileStore[(Physical File Storage)] - end - - subgraph "Conversation 1" - Conv1[Conversation ID: 1] - File1A["File: report.pdf (v1)"] - File1B["File: report.pdf (v2)"] - Conv1 --- File1A - Conv1 --- File1B - end - - subgraph "Conversation 2" - Conv2[Conversation ID: 2] - File2["File: report.pdf (v1)"] - Conv2 --- File2 - end - - subgraph "Message References" - Msg1["Message 1: with filenames=['report.pdf']"] - Msg2["Message 2: with filenames=['report.pdf']"] - end - - File1A -- "Version 1" --> FileStore - File1B -- "Version 2" --> FileStore - File2 -- "Version 1" --> FileStore - - File1A --> FileDB - File1B --> FileDB - File2 --> FileDB - - Msg1 -. "References latest version (v2)" .-> File1B - Msg2 -. "References latest version (v1)" .-> File2 - - classDef conversation fill:#f9f,stroke:#333,stroke-width:2px - classDef file fill:#bbf,stroke:#333 - classDef storage fill:#bfb,stroke:#333 - classDef message fill:#fbb,stroke:#333 - - class Conv1,Conv2 conversation - class File1A,File1B,File2 file - class FileDB,FileStore storage - class Msg1,Msg2 message -``` - -### File Versioning - -- **Automatic Versioning**: When a file with the same name is uploaded to a conversation, it's treated as a new version -- **Version Control**: The system maintains version numbers and history -- **Access Control**: API allows requesting specific versions or defaulting to latest -- **Conflict Management**: No built-in conflict resolution for simultaneous updates - -```mermaid -sequenceDiagram - participant User1 as User 1 - participant Conv as Conversation - participant DB as Database - participant FS as File Storage - - User1->>Conv: Upload "report.pdf" (v1) - Conv->>DB: Create file record<br/>conversation_id: 123<br/>filename: "report.pdf"<br/>version: 1 - Conv->>FS: Store file content with<br/>hash-based path - - Note over User1,FS: Later... - - User1->>Conv: Upload new "report.pdf" - Conv->>DB: Find existing file with<br/>same name in conversation - Conv->>DB: Create new version<br/>conversation_id: 123<br/>filename: "report.pdf"<br/>version: 2 - Conv->>FS: Store new content with<br/>different hash-based path - - Note over User1,FS: Request file - - User1->>Conv: Request "report.pdf" - Conv->>DB: Find file, get latest version (v2) - Conv->>FS: Retrieve content for version 2 - Conv->>User1: Return file content -``` - -### File Naming - -- **Namespace**: Filenames must be unique within a conversation -- **Constraints**: Database enforces uniqueness via constraints -- **Workaround**: To have logically different files with the same name, users must use different filenames (e.g., "report-v2.pdf") - -## Data Export/Import - -### Serialization - -- Both conversations and assistants support export/import -- Data can be transferred between instances while maintaining relationships -- Export includes messages, files, and metadata - -### Persistence - -- Database uses SQLModel with async SQLAlchemy -- Supports both SQLite and PostgreSQL databases - -## Access Patterns - -### Direct API Calls - -- Assistants can make authenticated API calls to the Workbench service -- API endpoints available for conversation, message, and file operations - -### Message Commands - -- Assistants can respond to commands embedded in messages -- Command responses can include structured data in metadata - -### Event Subscriptions - -- Assistants can subscribe to conversation events -- Real-time notifications of changes to conversations -- Requires maintaining active connections to receive events -- No guarantee of delivery - events during disconnections are lost -- Cannot be used as a reliable state synchronization mechanism - -## Best Practices - -### State Persistence - -- Use message metadata for small conversation-specific state (<100KB) -- Use assistant state for cross-conversation persistence that needs UI integration -- Use files for larger structured data or binary content that users should see -- Use assistant storage directory for private cross-conversation data - -```mermaid -graph TD - State[State to Store] - - State -- "Small, conversation-specific<br/>(<100KB)" --> MessageMeta[Message Metadata] - State -- "Shared across conversations<br/>UI integration needed" --> AssistantState[Assistant State API] - State -- "Large data<br/>Binary content<br/>User visibility needed" --> Files[Conversation Files] - State -- "Cross-conversation<br/>Private to assistant<br/>No UI needed" --> LocalStorage[Assistant Storage Directory] - - MessageMeta --> Persist[(Database)] - AssistantState --> Persist - Files --> FileStore[(File Storage)] - LocalStorage --> Disk[(Local Filesystem)] - - classDef decision fill:#f5a9b8,stroke:#333 - classDef storage fill:#dae8fc,stroke:#6c8ebf - classDef persist fill:#d5e8d4,stroke:#82b366 - - class State decision - class MessageMeta,AssistantState,Files,LocalStorage storage - class Persist,FileStore,Disk persist -``` - -### Sharing Information - -- Use conversation sharing for explicit permission grants -- Leverage the API for controlled cross-conversation access -- Use message metadata for lightweight information transfer -- For file sharing between conversations, implement explicit copy mechanisms - -```mermaid -flowchart TD - Coordinator[Coordinator Conversation] - Team[Team Conversation] - - subgraph "Information Sharing Options" - direction TB - A[Message Metadata] - B[File Transfer] - C[Cross-conversation Messages] - D[Assistant State] - end - - Coordinator -- "Small data<br/>(<100KB)" --> A --> Team - Coordinator -- "Large/structured data<br/>User visibility" --> B --> Team - Coordinator -- "Real-time notifications" --> C --> Team - Coordinator -- "Persistent shared state" --> D --> Team - - classDef conv fill:#f9f,stroke:#333 - classDef option fill:#bbf,stroke:#333 - - class Coordinator,Team conv - class A,B,C,D option -``` - -### Avoiding State Conflicts - -- Establish clear ownership of data to prevent conflicting updates -- Use versioning for tracking changes over time -- Implement conflict detection and resolution for collaborative scenarios -- Use timestamps to determine the most recent updates - -```mermaid -sequenceDiagram - participant ConvA as Conversation A - participant Central as Central State - participant ConvB as Conversation B - - Note over ConvA,ConvB: State Synchronization Pattern - - ConvA->>Central: Read current state - ConvA->>ConvA: Local modifications - ConvA->>Central: Write with version check - Central-->>ConvA: Update confirmed (v1) - - ConvB->>Central: Read current state (v1) - ConvB->>ConvB: Local modifications - - ConvA->>Central: Read current state (v1) - ConvA->>ConvA: More local modifications - ConvA->>Central: Write with version check - Central-->>ConvA: Update confirmed (v2) - - ConvB->>Central: Write with version check (v1) - Central--xConvB: Conflict detected (current v2) - ConvB->>Central: Read current state (v2) - ConvB->>ConvB: Merge changes - ConvB->>Central: Write with version check (v2) - Central-->>ConvB: Update confirmed (v3) -``` \ No newline at end of file diff --git a/assistants/project-assistant/docs/design/PXL_20250814_190140267.jpg b/assistants/project-assistant/docs/design/PXL_20250814_190140267.jpg new file mode 100644 index 000000000..4cce1d332 Binary files /dev/null and b/assistants/project-assistant/docs/design/PXL_20250814_190140267.jpg differ diff --git a/assistants/project-assistant/docs/design/actions.md b/assistants/project-assistant/docs/design/actions.md new file mode 100644 index 000000000..e4684fd8a --- /dev/null +++ b/assistants/project-assistant/docs/design/actions.md @@ -0,0 +1,9 @@ +# Actions + +It is important that when actions can be taken they are. Sometimes a single response should result in numerous actions. + +I'm not sure... maybe more rigorous instructions about taking multiple actions, maybe maintain action lists and make sure they all get taken, maybe evaluate if there were any actions we could have taken but didn't, and then do them. + +I don't recall off the top of my head, but the idea is to use gpt-4-mini to ask "was there a promise to call a tool in the last assistant response, if so what tool call should we make" when we got the last message that didn't include a tool, then if so go run the tool and inject the results into the conversation history as if the original llm had made the call... I had to do this for the early reasoning models that really just never wanted to call tools. Maybe look and see if I did it in Explorer or Codespace Assistant? + +4.1 \ No newline at end of file diff --git a/assistants/project-assistant/docs/design/control_options.md b/assistants/project-assistant/docs/design/control_options.md new file mode 100644 index 000000000..024ffbd51 --- /dev/null +++ b/assistants/project-assistant/docs/design/control_options.md @@ -0,0 +1,52 @@ +# Designing KTA assistant architecture + +## The simple job at hand + +Help prepare a knowledge package. +- Define audience and takeaways + +## Task organization breakdown + +- ToDo (structured task list) +- Tasks +- Tool calls + +## Types of context + +- kt-process: The knowledge transfer process while in coordinator role. +- share: state of the knowledge share +- kb: State of the knowledge base +- messages: previous conversation +- task-list + +## Different roles/recipes/types-of-assistants + +### Task completer + +- Role: Conversational assistant +- Instructions + - Collaborate with user to complete tasks. +- Context + - kt-process + - share + - kb + - messages +- Tools +- Output + +- Audience +- Knowledge Base management +- Gaps detector +- Invitation +- Information requests +- Knowledge digest +- Knowledge brief +- Welcome message generation + +## Thoughts + +- Should the +- Who executes? + - If the agents... they need to do so reliably and give a trace. + +"Now I should start thinking about..." diff --git a/assistants/project-assistant/docs/design/design.md b/assistants/project-assistant/docs/design/design.md new file mode 100644 index 000000000..17642c05d --- /dev/null +++ b/assistants/project-assistant/docs/design/design.md @@ -0,0 +1,131 @@ +# Knowledge Transfer + +## Clear separation of state + +- Conversation state: messages and attachments +- Knowledge Base meta for the assistant to guide the producer conversation + - Audience + - takeaways + - Objectives and outcomes + - Gaps +- Knowledge Base + - Digest + - Brief + - Files + - Facts + +## Producer assistance + +- Active listening +- Stage aware (audience, takeaways, kb prep, objectives/outcomes, brief, share msg) +- Use multi-agent feedback (ultra-think, panel of experts); especially on kb prep +- Produces KB + - Use grounding eval against all generated docs. + - Make kb docs visible for user modification. + +## Semantics vs Style + +### Semantics Approaches + +Fact lists + +Concepts + +Markdown (trees) + +## Knowledge Base Approaches + +Documents + +Trees + +Graphs + +## Producer Assistance + + + +## Learning Styles + +Maintaining preferences of the learner, such as: + +- visual or concrete examples +- pacing +- academic or plain-speaking +- open exploration or guided (preferred learning modes) + +## Learning Modes + +### Explorer + +The knowledge base is explored by the consumer asking the assistant to help in whichever way the consumer would like. + +### Self-guided Curriculum + +Learning objectives and outcomes are set by the producer. The progression is suggested, but the learner can go in any order. The system tracks when outcomes have been achieved. + +### Interviewee + +The producer/assistant creates a list of concepts or topics that the user will be taken through, progressing through Bloom's Taxonomy for each. + +## Issues + +Fluff + +Style. + +How is this better than one long doc? + +## Other things to try + +- Using Bloom's taxonomy to instruct the assistant for what it should do on the next turn. +- Scoring the user's taxonomy level on a given topic. +- Breaking larger corpus into lists of topics. +- Engaged/Frustrated/Confused detection. +- Engaged/Frustrated response adaptation. +- Learning style adaptation (concrete examples vs visual diagrams) +- Learner pace preference adaptation (faster/slower) +- Topic prerequisite detection (detecting which topics should be covered first) +- Response pattern rotation + + ## Related Research + + You’ve hit on a fundamental issue! The pre-AI-winter symbolic AI folks absolutely did make this distinction - they saw content/information as separable from its presentation/style. This shows up in several key areas of literature: + +Classical AI/Knowledge Representation: + +- Minsky’s Frames (1974) - attempted to separate semantic content from surface realization +- Schank’s Conceptual Dependency Theory - tried to represent meaning independent of linguistic form +- Knowledge Interchange Format (KIF) - explicitly designed to separate logical content from syntactic presentation + +Linguistics/Philosophy: + +- Frege’s sense/reference distinction - the foundational idea that meaning exists apart from expression +- Chomsky’s deep structure vs. surface structure - competence vs. performance +- Grice’s work on implicature - what’s said vs. what’s meant + +Modern tensions: + +The “distributional hypothesis” that underlies modern NLP (“you shall know a word by the company it keeps”) fundamentally entangles meaning with usage patterns. Some relevant recent work: + +- “Language Models are Few-Shot Learners” (GPT-3 paper) shows style and content are learned together +- Work on disentangled representations (like style transfer research) tries to separate these again +- Prompt engineering literature increasingly recognizes that HOW you ask affects WHAT you get + +The irony is that the old symbolic approaches failed partly because they couldn’t handle the messiness of natural language, while modern approaches succeed by embracing that messiness - but lose the clean content/style separation in the process. + +The knowledge representation vs. connectionist debate has some fascinating historical documents. +https://arxiv.org/pdf/1703.00955 + +“Toward Controlled Generation of Text” by Hu et al. (ICML 2017) is a singular representative study that directly addresses the content/style disentanglement issue in neural language models. + +This paper is particularly relevant because it: + +1. Explicitly tackles disentanglement: The paper aims at generating plausible text sentences whose attributes are controlled by learning disentangled latent representations with designated semantics +1. Separates content from style: The model augments unstructured variables z with a set of structured variables c each of which targets a salient and independent semantic feature of sentences +1. Addresses the core problem: The authors note that varying individual code may result in unexpected variation of other unspecified attributes besides the desired one - exactly the entanglement problem you’re describing +1. Introduces explicit independence constraints: To ensure true disentanglement, they introduce the independency constraint which separates these attributes with c by enforcing them to be fully captured by the unstructured part z + +The paper demonstrates this separation empirically - when they control for sentiment (a “style” attribute), other aspects like subject matter and tone (the “content”) remain unchanged when their independence constraint is active. Without it, changing the style inadvertently changes the content as well. + +This work bridges the gap between the symbolic AI tradition (which had clean content/style separation but couldn’t handle natural language well) and modern neural approaches (which handle language well but entangle everything together). It’s a foundational paper in the area of controllable text generation that directly addresses your concern about the lack of distinction between information and style in LLMs. diff --git a/assistants/project-assistant/docs/design/inference.md b/assistants/project-assistant/docs/design/inference.md new file mode 100644 index 000000000..98beda64f --- /dev/null +++ b/assistants/project-assistant/docs/design/inference.md @@ -0,0 +1,53 @@ +# Inference + +Sub-agents should be watching for information they are responsible for gathering. When they see some, they should confirm with the user that they should take action with it. + +Instead of just writing out the next suggested action, we should see if there is already info in the context for it, and suggest as more of a proposal than just "let's talk about X next". In other words, don't ask open-ended questions. + +Things the assistant should infer at appropriate times: + +- On production side: + - What are the takeaways? + - Who is the audience? + - What is the user's preferred communication style? + - What should be in the brief (what all is included in this knowledge share?) + - Are there any learning objectives or learning outcomes that make sense to be tracked for the consumers? + - Are there any gaps in the shared knowledge that need to be filled in? + - What are the overall topics of this knowledge share that we need to make sure are transferred clearly? + - How might the user want to communicate this share to others (taking into account the audience, takeaways, communication style, etc.)? +- On the consumer side: + - How well is this consumer doing in progressing through the information? + - Have they met the learning objectives and outcomes? + - Have they grokked the takeaways? + - Has the info they asked about actually been shared? Should I create a request? + - Is the consumer exhibiting any confusion? + - Is the consumer progressing through bloom on all important topics? + - What is the user's preferred communication style? + +## BK's original message + +I have a pattern that I'd like to explore for improving our experience developing assistants. There are times where assistants need to gather info from a user but likely has enough context from the current conversation history, attachments, etc., to make a pretty "educated guess" and infer the right answer. For example, we may want to capture information about the desired audience for a document or knowledge transfer session so that we can use that to inform other processes in our assistant guidance and/or workflow. Instead of explicitly asking the user for this, maybe we do something that will let us do more of the work for the user. + +Let's take the example of what you just asked me here. You gave me a whole bunch of things up front. You let me know what all you can help with, you asked me to share my content or ideas, but then you also ask me about my audience. You talk about the knowledge brief (which btw, I don't see yet, is that a bug? You said, 'In the side panel, you can see your "knowledge brief"'.) and let me know I can ask you to update it at any time (is that burden on me now, or is it optional for me because really you are managing it? I hope it's the latter, I'm hoping you can do more for me and reduce my burden). Then you close with once again asking me about the audience. + +Ok, so that's a LOT to take in on the first interaction of the conversation. That's also a lot of text to read, and honestly this is the first time I've read it ALL and I have heard from others that they would just skim this much content and move right to chatting, so we should think about how to support that WHEN (not IF) it happens... + +But regardless, what if we took a different approach, what if we instead asked purely about the content or ideas - or even better yet, since the whole point of this experience is that we're trying to transfer knowledge to someone else (or many others), maybe we should start with asking what the desired takeaways are for those we're going to share it with; then you can be more helpful in making sure you have what you need from me to do so (tip: for THIS conversation, my takeaway that I want for my recipients is that they understand the proposed feature I want to have implemented and that you have enough info to answer their questions on the "why" behind it, etc.) + +Ok, so if asked for that kind of context _first_, then it seems when we actually want/need the audience info (don't think it's critical on the first interaction), there MAY be a lot of contextual hints to at least point us in the right direction. Look at what I've said so far... I have indicated a takeaway that includes implementing a feature within our assistant code, so that says a LOT about who are audience IS and also who it ISN'T for this particular conversation. I bet you could probably even assert something like "Let's talk about your audience, it appears we want to target developers would implement these features in code <... etc.>, is this correct? Is there anything else you want to tell me?" from that info. + +If you do that, then here is the impact on the user felt experience. If it's wrong, it's not really much more work for the user to share what the actual audience is than the current approach of asking the very open-ended question "Who are you going to be sharing your knowledge with?" I suspect, however, that every conversation will have at least some hints and even with the least contextual hints, what you infer and assert may be better than the blank-slate question and allow for users to provide feedback and tweak it - or, in the ideal case, you nail it and the user can just say "yep" and continue on. Win! We call this "designing for failure with AI" and it's the idea that we want to use AI in smart ways when we can, but we should assume we're going to be wrong and put it into place in ways were we're not creating extra burden for the user for how they would already do a thing (in this case, answering a fully open-ended question from the assistant about the target audience), but in the cases where we are right can be delighters for the user, or in cases like this with NL, "degrees" of being right or partially so can be somewhere on the spectrum between and at least reduce user burden and potentially help make it easier for them to answer. + +There are many other places where this could apply just in the knowledge transfer scenario. For example, we also care about the communication style that users on both side (the knowledge transfer coordinator and the recipients) would benefit from you using with them. Sure, again we could ask them directly, OR after a few interactions we could take cues from how they are interacting with you and then follow the same infer/assert but confirm pattern. We might apply the same approach if asked to create a blurb to paste into other conversations/docs/emails with the share link to get a better feel for what level of detail, tone, etc. to put into that. + +So here is my proposal. Since this is a pattern I expect exists more frequently than we realize, let's start looking for it more often and let's consider how we make any code we write for this as something more reusable in that way. I generally think it should go something like this: + +* Identify the scenario in which we want to try this out within the flow of a conversation/workflow +* Have some level of conversation to build up some context +* Hook into whatever assistant response flow we have designed that would allow us to determine "when" we are ready to invoke this behavior and then do so +* Pass that conversation history (and relevant attachments, etc.) into an LLM call that asks for a response that could be returned as the next assistant message that infers the answer to the question we have, asserts it to the user in an observational way while also confirming with them if it is correct or feedback that is needed +* Insert that message into the conversation history as the assistant and let the conversation/workflow proceed as before + +By doing this as a sub-step, with a separate context + instructions than the general conversation/workflow or assistant response flow, we can both build and receive a more focused request as we use the LLM. Also, this makes for a nice way to abstract this out for re-use. It can become a function that is called, pass in the right context + instruction and insert the message back into the conversation, making it easy to do more often and also to later invest more in the "under the hood" of that function if/as needed. + +Ok, that's a lot, let's start there and see where we're at. What do you think? Do you have enough to answer any reasonable questions you would expect from our recipients of this knowledge transfer, and do you have enough to propose a plan for how they would build it, in collaboration with them providing more context on their code, etc.? \ No newline at end of file diff --git a/assistants/project-assistant/docs/how-kta-works.md b/assistants/project-assistant/docs/how-kta-works.md new file mode 100644 index 000000000..fcf096827 --- /dev/null +++ b/assistants/project-assistant/docs/how-kta-works.md @@ -0,0 +1,61 @@ +# Understanding how the Knowledge Transfer Assistant + +The Knowledge Transfer Assistant isn't simply a chat conversation with an assistant. + +It uses a "more code than model", recipe-inspired approach that runs "meta-cognitive operations" to create an assistant that guides users through the knowledge transfer process. + +The Knowledge Transfer Assistant is an assistant experience that runs within _MADE: Exploration's_ Semantic Workbench. + +## How it works + +The Knowledge Transfer Assistant uses a multi-conversation architecture to separate knowledge creation from knowledge consumption, addressing the confusion that arises when multiple participants work in the same shared conversation. + +### Dual-Conversation Architecture + +The system operates through two conversation types: + +1. **Coordinator Conversation**: Where knowledge creators collaborate to structure and package their knowledge +2. **Team Conversations**: Individual conversations created for each team member who joins the knowledge transfer + +### Knowledge Artifacts + +The system structures informal knowledge into four transferable artifacts: + +- **Knowledge Brief**: Context, scope, and timeline summary +- **Knowledge Digest**: An organized outline of all knowledge from coordinator conversations and attachments, automatically updated by LLM analysis. Contains high information density with key concepts, decisions, facts, and context—serving as the primary knowledge reference for team members +- **Learning Objectives**: Structured goals with measurable outcomes +- **Information Requests**: Bidirectional Q&A channel with priority levels + +### Cross-Conversation Communication + +Isolated conversations communicate through centralized components: + +- **Share Storage**: Maintains knowledge packages and progress across conversations +- **Share Manager**: Creates shareable team conversations via secure URLs +- **Notifications System**: Enables updates between coordinators and team members +- **File Synchronization**: Propagates documents across relevant conversations + +### User Experience + +**Coordinators:** +- Navigate to https://semantic-workbench.azurewebsites.net/ and start a new conversation with the Knowledge Transfer Assistant. +- The assistant will guide you through: + - Define learning objectives + - Develop content through natural conversation and file attachments + - Create a knowledge brief and invitation messages for your team members + - Share the provided Share Link with your team members. +- Monitor team progress via real-time dashboards and notifications +- Respond to information requests + +**Team Members:** +- Join a new conversation via the coordinator-provided share URL, creating individual conversation +- Receive personalized welcome and knowledge brief +- Work through objectives with assistant guidance +- Access synchronized files and updated digest +- Submit questions through information requests + +### Technical Implementation + +The system uses orchestration logic to coordinate multiple AI assistants rather than relying on a single large model. This enables role-specific assistant behavior, persistent state across conversation boundaries, and real-time synchronization between coordinator and team conversations. + +The architecture allows multiple team members to join knowledge transfers independently while maintaining separation between knowledge creation and consumption workflows. diff --git a/assistants/project-assistant/docs/knowledge-transfer-goals.md b/assistants/project-assistant/docs/knowledge-transfer-goals.md new file mode 100644 index 000000000..fdf962bd8 --- /dev/null +++ b/assistants/project-assistant/docs/knowledge-transfer-goals.md @@ -0,0 +1,32 @@ +# Goals for the Knowledge Transfer Assistant + +## Core idea + +Assist users in synthesizing knowledge from data and ideas and transfer that knowledge to others. + +## Shortcomings of existing solutions + +- Even though assistants can help do this well, it normally requires the user to have the idea to use an assistant this way and to guide the assistant through the process. +- While we had previously used shared (single) assistant conversations to do this, it was confusing for users to all work in the same conversation. +- Users could opt to instead create a copy of the original conversation, but that suffered due to the fact that it was now disconnected from any ongoing development in each of the conversations. + +## Our solution + +### Give both knowledge producers and knowledge learners individual conversations + +This solution addresses all of these items with a creative take on the scenario through separate conversations with an assistant that has built-in guidance and ability to work across all of the conversations. + +Splitting the producer/consumer (or coordinator/team) conversations unlocked many more interesting benefits: + +- Multiple producers can collaborate within a single conversation to set up the knowledge share. +- The knowledge share can be transferred to multiple recipients. +- Since each conversation has its own assistant, we have the assistants manage communication between producers and consumers through "information requests". +- Each conversation assistant can adopt a communication style preferred by it's users. + +### Learning progress + +In order to guide consumers through the knowledge transfer experience, we introduced the idea of "learning objectives" and "learning outcomes". The producer and their assistant can define what objectives and outcomes they desire their learning audience to achieve. The learners are assisted by their assistant in achieving those outcomes. The assistants guide them through their material at a pace and way that they prefer. When learners achieve outcomes, the producers are notified. This provides clear feedback to all parties of the progression through the knowledge transfer. + +## Read more about the problem space + +See [Knowledge Transfer Jobs-to-be-done](./JTBD.md) diff --git a/assistants/project-assistant/docs/learning_assistance.md b/assistants/project-assistant/docs/learning_assistance.md new file mode 100644 index 000000000..7ae0e4cb2 --- /dev/null +++ b/assistants/project-assistant/docs/learning_assistance.md @@ -0,0 +1,8 @@ +# Instructions for the Learning Assistant + +- Identify 2 to 3 concrete opportunities and "how to's" for using the Knowledge Transfer Assistant for the user that are customized to the needs & opportunities specific to that user. +- Find out more about what the user does and help them ideate where this assistant could help. +- After brainstorming, provide the user with specific steps: + - How to create a new Knowledge Transfer Assistant experience. + - Help them understand what kinds of files could be uploaded to benefit this. + - Help them understand what prompts/things-to-say they could use to help them get the ball rolling. diff --git a/assistants/project-assistant/docs/CLAUDE_PROMPTS.md b/assistants/project-assistant/docs/notable_claude_conversations/CLAUDE_PROMPTS.md similarity index 76% rename from assistants/project-assistant/docs/CLAUDE_PROMPTS.md rename to assistants/project-assistant/docs/notable_claude_conversations/CLAUDE_PROMPTS.md index 51966fcc5..9b60bcee9 100644 --- a/assistants/project-assistant/docs/CLAUDE_PROMPTS.md +++ b/assistants/project-assistant/docs/notable_claude_conversations/CLAUDE_PROMPTS.md @@ -7,3 +7,7 @@ │ we have a copy difference between the two templates, the copy should be located in config.py. - Let's keep working on the project-assistant project (assistants/project-assistant). Please reread the notes in docs/DESIGN.md for the big picture. Also, as we work on code, please respect the docs/DEV_GUIDE.md and check out some notes on relevant libraries at docs/ASSISTANT_LIBRARY_NOTES.md, and docs/WORKBENCH_NOTES.md. I have recorded our most recent work in docs/WORKING.md. Once you have read all those, let me know and I'll tell you what we're working on now. - You have access to logs in .data/logs. We're trying to fix the missing welcome message after a share link is redeemed. You can see what code we've changed using git recently to add more logging. Check out the code and logs and help me figure out why welcome messages aren't getting added on shared team conversations. +- Excellent. Let's move on to another feature. I'd like to add a new optional string field to KnowledgePackage named "audience". The suggest_next_action function should suggest defining the audience as one of + the first things for the user to do when the assistant is in coordinator role. The point is to understand who the expected audience of the knowledge transfer is and how much they already know on the topic so + that the assistant can help the coordinator ensure the information gathered in the knowledge package is sufficient. +- I would like to do a data refactor. Currently, the share_data.json (which is a reflection of KnowledgePackage in assistant/data.py) stores whether learning outcomes have been achieved in the learning objective definitions themselves. This is wrong because we may have multiple redemptions from different individuals. What we actually need to do is capture the first participant in a conversation (other than an assistant) as the "redeemer" that conversation and then track their outcome achievements with their own data record. Currently, we are tracking a redemption as a "linked_conversations" attribute in the assistant's .data directory. Perhaps we should track that along with other information about redeemed conversations in the share_data.json, too, perhaps under a key named "team_conversations". WDYT? Give me a proposal and a list of changes that wou need to be made. diff --git a/assistants/project-assistant/docs/notable_claude_conversations/transfer_state.md b/assistants/project-assistant/docs/notable_claude_conversations/transfer_state.md new file mode 100644 index 000000000..725cd0dda --- /dev/null +++ b/assistants/project-assistant/docs/notable_claude_conversations/transfer_state.md @@ -0,0 +1,231 @@ +> Nice. So, if the coordinator is getting suggestions for the next action based on the state of the share, do we really need to idea of KnowledgePackage.transfer_state? Please think deeply about how we are using + transfer_state and give me a recommendation of whether to keep it or not. + +✻ Thinking… + + The user is asking me to think deeply about the usage of transfer_state in the KnowledgePackage model and whether we really need it now that we have the next action suggestion system in place. + + Let me analyze how transfer_state is currently being used: + + 1. Current transfer states: + - ORGANIZING: Initial state - Coordinator is capturing and organizing knowledge + - READY_FOR_TRANSFER: Knowledge is organized and ready for team members to access + - ACTIVE_TRANSFER: Team members are actively learning and exploring the knowledge + - COMPLETED: Learning objectives have been achieved and the transfer is complete + - ARCHIVED: Knowledge package was archived or is no longer active + 2. Where transfer_state is used: + Let me search for usages of transfer_state in the codebase to understand how it's being used. + + From what I can see in the manager.py snippet: + +- Line 1353: ready_for_transfer = package.transfer_state == KnowledgeTransferState.READY_FOR_TRANSFER +- It's used to check if the package is ready for transfer + + Let me think about this more systematically: + + Arguments for keeping transfer_state: + + 1. Explicit state tracking: It provides a clear, explicit way to track where the knowledge transfer process is + 2. Multi-conversation coordination: Since there are coordinator and team conversations, the transfer_state could help coordinate between them + 3. UI/Dashboard purposes: The state could be useful for showing progress in dashboards or UIs + 4. Workflow gates: It could serve as a gate to prevent certain actions until the package is ready + 5. Audit trail: It provides a clear audit trail of progression through phases + 6. External integrations: Other systems might need to know the state of the transfer + + Arguments for removing transfer_state: + + 1. Redundant with suggestion system: The next action suggestions already encode the logic about what should happen next based on the current state + + 2. Derivable from other fields: The state could be derived from other fields like brief existence, objectives, and achieved outcomes + 3. Manual maintenance burden: Updating transfer_state manually could lead to synchronization issues with actual progress + 4. Simpler model: Removing it would streamline the data model + 5. Implicit vs explicit: Current state can be determined implicitly from existing data without explicit state management + + Key considerations: + I need to investigate how team conversations coordinate and track completion effectively without an explicit transfer state. The challenge lies in creating a robust mechanism for tracking knowledge transfer + progress that doesn't rely on manual state updates. + + Potential state derivation strategies emerge from examining different transfer stages: + +- Organizing state links directly to document completeness +- Ready for transfer indicates all prerequisite information exists +- Completion could be automatically detected through learning outcome achievement +- Archiving might require a genuine manual intervention + + The critical question centers on whether the transfer_state provides unique functionality beyond what can be achieved through intelligent suggestion systems and derived state tracking. + + Current implementation suggests the primary purpose involves readiness verification for transfer initiation. However, several nuanced considerations complicate simple state elimination: + +- Team access control requires precise state management +- Share URL generation needs clear readiness criteria +- Tracking state transitions provides valuable historical context +- Manual coordinator interventions demand flexible state definitions + + A streamlined approach involves reducing transfer states to four core stages: draft, published, completed, and archived. This strategy simplifies state management while preserving essential workflow + flexibility, allowing the suggestion system to handle intermediate organizational complexities. + + I'll investigate the codebase thoroughly to confirm comprehensive understanding of transfer_state usage before finalizing recommendations. + +● Bash(rg "transfer_state" . -A 2 -B 2) + ⎿ ./assistant/state_inspector.py- # Display knowledge transfer stage + ./assistant/state_inspector.py- stage_label = "📋 Organizing Knowledge" + ./assistant/state_inspector.py: if share_info and share_info.transfer_state: + ./assistant/state_inspector.py: if share_info.transfer_state == KnowledgeTransferState.ORGANIZING: + … +157 lines (ctrl+r to expand) + +✻ Thinking… + + Now I have a comprehensive view of how transfer_state is used across the codebase. Let me analyze each usage: + + Current Usages of transfer_state: + + 1. State Inspector (assistant/state_inspector.py) + +- Shows different stage labels for coordinators and team members based on the transfer_state +- This is purely cosmetic/informational + + 2. Data Model (assistant/data.py) + +- Defines the field with default value ORGANIZING + + 3. Command Processor (assistant/command_processor.py) + +- Shows current status in share info display +- Again, purely informational + + 4. Tools (assistant/tools.py) + +- Sets initial state to ORGANIZING when creating a new package +- Updates to READY_FOR_TRANSFER when marking ready +- Updates to COMPLETED when knowledge transfer is finished +- These are explicit state transitions triggered by tool calls + + 5. Manager (assistant/manager.py) + +- Returns current transfer state +- Updates transfer state when status is changed +- Logs state changes +- Checks if package is ready for transfer - this is the main logic use + + 6. Tests + +- Various test setups and assertions + + Analysis: + + Looking at this usage, I can see several patterns: + + Informational/Display uses (could be derived): + +- State inspector labels +- Command processor status display +- These just show the current state to users + + State transition tracking (explicit state management): + +- Tools that explicitly move between states +- Manager methods that update state +- Logging of state changes + + Business logic (the key question): + +- The main business logic use is in manager.py line 1353: ready_for_transfer = package.transfer_state == KnowledgeTransferState.READY_FOR_TRANSFER +- This gates whether certain suggestions are made + + Key insights: + + 1. The business logic use is minimal - it's really just checking if the state is READY_FOR_TRANSFER + + 2. Most uses are informational - showing labels and status to users + 3. State transitions are explicit - tools deliberately move between states + 4. The ready-for-transfer check could be derived from other conditions + + I'm exploring potential alternative approaches to state management. The current state tracking seems overly complex for its actual usage. Most state changes appear to be manual tool-driven transitions, with + minimal automated logic. + + The core challenge is determining precise conditions for state changes. Potential improvements could involve more dynamic, condition-based state transitions that automatically update based on knowledge transfer + completeness and prerequisite fulfillment. + + Interesting observations emerge about the granularity of state tracking. While multiple states exist, the actual business logic relies on very simple binary checks - essentially whether a package is ready for + transfer or not. + + The recommendation leans towards simplifying the state management system, potentially removing explicit state tracking in favor of dynamically derived conditions that reflect the package's actual progress. + + Key arguments favor removing the transfer state: the suggestion system provides more intelligent, condition-based guidance, and maintaining a separate state field introduces unnecessary complexity. The + proposed approach shifts from static state checking to a more dynamic, action-oriented model where coordinators focus on determining the next appropriate step. + + Transition strategies include replacing explicit state checks with methods that evaluate current conditions, and updating display mechanisms to show derived state labels. This approach creates a more flexible, + maintainable system that relies on actual package conditions rather than potentially outdated state markers. + + The core principle is establishing a single source of truth through intelligent suggestion logic, reducing maintenance overhead and creating a more intuitive workflow for coordinators. + +● Looking at the codebase usage of transfer_state, I can see it's used in several places. Let me analyze whether we actually need it: + +● Bash(rg -A 5 -B 5 "ready_for_transfer.*transfer_state" assistant/) + ⎿ assistant/manager.py- return ( + assistant/manager.py- "Knowledge package has no learning objectives. Add at least one learning objective with outcomes." + assistant/manager.py- ) + assistant/manager.py- + … +7 lines (ctrl+r to expand) + +✻ Thinking… + + Perfect! This confirms my analysis. The main business logic use of transfer_state is just to check if it equals READY_FOR_TRANSFER, and then the code immediately does its own logic to determine if it's + actually ready (has_objectives and has_outcomes). + + This is a perfect example of why the explicit state is redundant - the code is already deriving the actual readiness from the data conditions. + +● Recommendation: Remove transfer_state + + After analyzing the codebase usage, I strongly recommend removing the transfer_state field. Here's my reasoning: + + Current Usage Analysis: + + 1. Business Logic (1 place): Only used in manager.py to check == READY_FOR_TRANSFER, but then immediately derives actual readiness from data conditions anyway + 2. Display/Informational (3 places): + - State inspector labels + - Command processor status display + - Logging messages + 3. State Transitions (3 places): + - Tools explicitly set states during workflow + + Why Remove It: + + 1. Redundant with Suggestion System: The next action suggestions already encode the intelligent progression logic based on actual data conditions + 2. Derived State is Better: The "readiness" check in your code already derives state from actual conditions (has_objectives, has_outcomes, etc.) rather than trusting the stored state + 3. Maintenance Burden: Risk of state getting out of sync with reality - the stored state could say "READY_FOR_TRANSFER" but actually be missing objectives + 4. Simpler Mental Model: Instead of "what state am I in?", coordinators think "what should I do next?" which is more actionable + 5. Single Source of Truth: The suggestion logic should be the authoritative source of what happens next + + How to Replace It: + + 1. Business Logic: Replace the transfer_state check with a helper method: + def is_ready_for_transfer(package: KnowledgePackage) -> bool: + return (package.brief is not None and + package.learning_objectives and + any(obj.learning_outcomes for obj in package.learning_objectives)) + 2. Display Logic: State inspector can show derived labels: + def get_stage_label(package: KnowledgePackage) -> str: + if not package.brief: + return "📋 Creating Brief" + elif not package.audience: + return "🎯 Defining Audience" + elif not package.learning_objectives: + return "📚 Adding Objectives" + elif not is_ready_for_transfer(package): + return "📋 Organizing Knowledge" + else: + return "🚀 Ready for Transfer" + 3. Simple Archive Flag: Add a boolean archived: bool = False for packages that are explicitly archived + + Benefits: + +- ✅ Simpler data model (one less field to manage) +- ✅ Always accurate state (derived from actual conditions) +- ✅ Consistent with suggestion system (single source of workflow logic) +- ✅ Less maintenance (no risk of state/reality mismatch) +- ✅ More flexible (can easily change progression logic without state migration) + + The suggestion system you've built is already providing intelligent, context-aware guidance. The explicit transfer_state field is now redundant and potentially error-prone. + + Should I proceed with removing it? diff --git a/assistants/project-assistant/docs/opportunities-of-knowledge-transfer.md b/assistants/project-assistant/docs/opportunities-of-knowledge-transfer.md new file mode 100644 index 000000000..7f8394448 --- /dev/null +++ b/assistants/project-assistant/docs/opportunities-of-knowledge-transfer.md @@ -0,0 +1,43 @@ +# Knowledge Transfer Opportunities for Microsoft Research + +## Research Project Onboarding + +Scenario: A principal researcher leading a multi-year ML project needs to onboard new team members, visiting researchers, and collaborators throughout the project lifecycle. + +Knowledge Transfer Setup: +- Knowledge Brief: Project overview, research questions, current hypotheses, and timeline +- Learning Objectives: Understanding the codebase, reproducing baseline results, contributing to specific research directions +- Relevant Files: Codebase documentation, experimental logs, literature review summaries, dataset documentation + +Getting Started: +1. Create coordinator conversation: "Help me structure onboarding materials for our neural architecture search project" +2. Upload key documents: paper drafts, experiment tracking sheets, codebase README files +3. Define learning objectives: "New team members should be able to run our baseline experiments and understand our current research directions" + +## Cross-Team Research Methodology Transfer + +Scenario: A research team has developed novel evaluation methodologies or experimental frameworks that other teams want to adopt. + +Knowledge Transfer Setup: +- Knowledge Brief: Methodology overview, when to use it, expected outcomes +- Learning Objectives: Implementing the methodology, interpreting results, avoiding common pitfalls +- Relevant Files: Evaluation scripts, example datasets, results interpretation guides + +Getting Started: +1. Create coordinator conversation: "I need to share our evaluation methodology with three other research teams" +2. Upload methodology documents: evaluation protocols, example analyses, troubleshooting guides +3. Set learning outcomes: "Teams should successfully implement our evaluation pipeline and interpret results correctly" + +## Publication Knowledge Synthesis + +Scenario: Research group preparing major conference submissions needs to synthesize findings across multiple related projects and ensure all contributors understand the unified narrative. + +Knowledge Transfer Setup: +- Knowledge Brief: Paper scope, key contributions, submission timeline +- Learning Objectives: Understanding cross-project connections, contributing to specific paper sections, preparing for reviewer questions +- Relevant Files: Draft manuscripts, experimental results, related work surveys + +Getting Started: +1. Create coordinator conversation: "Help me organize our findings from three related projects into a coherent ICML submission" +2. Upload project materials: individual project summaries, experimental data, previous paper drafts +3. Define contributions: "Each author should understand how their work fits into the broader narrative and be prepared to defend specific claims" diff --git a/assistants/project-assistant/project-assistant.code-workspace b/assistants/project-assistant/knowledge-transfer-assistant.code-workspace similarity index 81% rename from assistants/project-assistant/project-assistant.code-workspace rename to assistants/project-assistant/knowledge-transfer-assistant.code-workspace index 4feef8ca4..abe0ae5e7 100644 --- a/assistants/project-assistant/project-assistant.code-workspace +++ b/assistants/project-assistant/knowledge-transfer-assistant.code-workspace @@ -15,7 +15,7 @@ "configurations": [ "app: semantic-workbench-app", "service: semantic-workbench-service", - "assistants: project-assistant" + "assistants: knowledge-transfer-assistant" ] } ] @@ -103,5 +103,20 @@ "ms-vscode.vscode-node-azure-pack", "tamasfe.even-better-toml" ] + }, + "settings": { + "markdown.validate.enabled": true, + "ruff.configuration": "${workspaceFolder:.multi-root-tools}/../ruff.toml", + "ruff.interpreter": ["${workspaceFolder:.multi-root-tools}/.venv"], + "ruff.importStrategy": "useBundled", + "ruff.nativeServer": "on", + "ruff.configurationPreference": "filesystemFirst", + "[python]": { + "editor.codeActionsOnSave": { + "source.organizeImports.ruff": "always" + }, + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.formatOnSave": true + } } } diff --git a/assistants/project-assistant/pyproject.toml b/assistants/project-assistant/pyproject.toml index 7b51063d1..731498ad0 100644 --- a/assistants/project-assistant/pyproject.toml +++ b/assistants/project-assistant/pyproject.toml @@ -2,17 +2,15 @@ name = "assistant" version = "0.1.0" description = "A file-sharing mediator assistant for collaborative projects." -authors = [{ name = "Semantic Workbench Team" }] +authors = [{ name = "MADE: Explore" }] readme = "README.md" requires-python = ">=3.11" dependencies = [ "assistant-extensions[attachments]>=0.1.0", "content-safety>=0.1.0", - "deepmerge>=2.0", "openai>=1.61.0", "openai-client>=0.1.0", "semantic-workbench-assistant>=0.1.0", - "tiktoken>=0.8.0", ] [dependency-groups] @@ -23,6 +21,9 @@ dev = [ "pyright>=1.1.389", ] +[tool.hatch.build.targets.wheel] +packages = ["assistant"] + [tool.uv] package = true @@ -46,3 +47,19 @@ log_cli_level = "WARNING" log_cli_format = "%(asctime)s | %(levelname)-7s | %(name)s | %(message)s" asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" + +# These ruff settings match /ruff.toml +# but include import sorting in the lint section. +# Leaving them all here for debugging temporarily. + +# [tool.ruff] +# line-length = 120 +# target-version = "py311" + +# [tool.ruff.lint] +# select = ["E4", "E7", "E9", "F", "I"] + +# [tool.ruff.format] +# docstring-code-format = true +# line-ending = "lf" +# preview = true diff --git a/assistants/project-assistant/test_inspector.py b/assistants/project-assistant/test_inspector.py deleted file mode 100644 index 54371fc35..000000000 --- a/assistants/project-assistant/test_inspector.py +++ /dev/null @@ -1,56 +0,0 @@ -""" -Test script to verify state inspector functionality. -""" - -import asyncio -import logging -from unittest.mock import AsyncMock, MagicMock - -from assistant.assistant import assistant -from semantic_workbench_api_model.workbench_model import AssistantStateEvent -from semantic_workbench_assistant.assistant_app import ConversationContext - -# Set up logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -async def test_inspector(): - """Test the state inspector provider.""" - # Create mock context - context = AsyncMock(spec=ConversationContext) - context.id = "test-conversation-id" - context.assistant = MagicMock() - context.assistant.id = "test-assistant-id" - - # Mock conversation - conversation = MagicMock() - conversation.metadata = {"setup_complete": True, "assistant_mode": "coordinator", "project_role": "coordinator"} - context.get_conversation.return_value = conversation - - # Create state event - state_event = AssistantStateEvent(state_id="project_status", event="focus", state=None) - - # Send event - logger.info("Sending state event...") - await context.send_conversation_state_event(state_event) - - # Get inspector provider - inspector_provider = assistant.inspector_state_providers.get("project_status") - if not inspector_provider: - logger.error("No project_status inspector provider found!") - return - - logger.info(f"Inspector provider found: {inspector_provider.display_name}") - - # Get state data - try: - state_data = await inspector_provider.get(context) - logger.info(f"State data: {state_data}") - except Exception as e: - logger.error(f"Error getting state data: {e}") - - -# Run the test -if __name__ == "__main__": - asyncio.run(test_inspector()) diff --git a/assistants/project-assistant/tests/test_artifact_loading.py b/assistants/project-assistant/tests/test_artifact_loading.py index 2101f9f92..a1cca2f6e 100644 --- a/assistants/project-assistant/tests/test_artifact_loading.py +++ b/assistants/project-assistant/tests/test_artifact_loading.py @@ -10,19 +10,22 @@ import uuid from typing import Any, TypeVar -from assistant.conversation_project_link import ConversationProjectManager -from assistant.project_data import Project, ProjectBrief, ProjectGoal, SuccessCriterion -from assistant.project_manager import ProjectManager -from assistant.project_storage import ProjectStorage, ProjectStorageManager -from assistant.project_storage_models import ConversationRole +from assistant.data import ( + ConversationRole, + KnowledgeBrief, + LearningObjective, + LearningOutcome, + Share, +) +from assistant.domain import KnowledgeBriefManager, ShareManager +from assistant.storage import ShareStorage, ShareStorageManager from semantic_workbench_assistant import settings -from semantic_workbench_assistant.storage import read_model, write_model # Type variable for better type annotations T = TypeVar("T") -class TestProjectStorage(unittest.IsolatedAsyncioTestCase): +class TestShareStorage(unittest.IsolatedAsyncioTestCase): """Test the project storage functionality with the new direct storage approach""" async def asyncSetUp(self): @@ -35,13 +38,13 @@ async def asyncSetUp(self): settings.storage.root = str(self.test_dir) # Create test project and conversation IDs - self.project_id = str(uuid.uuid4()) + self.share_id = str(uuid.uuid4()) self.conversation_id = str(uuid.uuid4()) self.user_id = "test-user-id" self.user_name = "Test User" # Create project directory structure - self.project_dir = ProjectStorageManager.get_project_dir(self.project_id) + self.project_dir = ShareStorageManager.get_share_dir(self.share_id) # Set up patching self.patches = [] @@ -66,13 +69,11 @@ def mock_storage_directory_for_context(context, *args, **kwargs): self.mock_storage_directory = patch1.start() self.patches.append(patch1) - # Patch get_associated_project_id - async def mock_get_associated_project_id(context): - return self.project_id + # Patch get_share_id + async def mock_get_share_id(context): + return self.share_id - patch2 = unittest.mock.patch.object( - ConversationProjectManager, "get_associated_project_id", side_effect=mock_get_associated_project_id - ) + patch2 = unittest.mock.patch.object(ShareManager, "get_share_id", side_effect=mock_get_share_id) self.mock_get_project = patch2.start() self.patches.append(patch2) @@ -81,13 +82,15 @@ async def mock_get_conversation_role(context): return ConversationRole.COORDINATOR patch3 = unittest.mock.patch.object( - ConversationProjectManager, "get_conversation_role", side_effect=mock_get_conversation_role + ShareManager, + "get_conversation_role", + side_effect=mock_get_conversation_role, ) self.mock_get_role = patch3.start() self.patches.append(patch3) # Create a test brief - self.title = "Test Project" + self.title = "Test KnowledgePackage" self.create_test_brief() async def asyncTearDown(self): @@ -105,82 +108,70 @@ async def asyncTearDown(self): def create_test_brief(self): """Create a test project brief in the project's shared directory""" # Create a project brief - test_goal = ProjectGoal( + test_goal = LearningObjective( name="Test Goal", description="This is a test goal", - success_criteria=[SuccessCriterion(description="Test criteria")], + learning_outcomes=[LearningOutcome(description="Test criteria")], ) - brief = ProjectBrief( + brief = KnowledgeBrief( title=self.title, - description="Test project description", + content="Test project description", created_by=self.user_id, updated_by=self.user_id, conversation_id=self.conversation_id, ) # Create a project with the goal - project = Project( - info=None, + project = Share( + share_id="test-share-id", brief=brief, - goals=[test_goal], - whiteboard=None, + learning_objectives=[test_goal], + digest=None, ) - # Write the project to storage - project_path = ProjectStorageManager.get_project_path(self.project_id) - project_path.parent.mkdir(parents=True, exist_ok=True) - write_model(project_path, project) - - # Write to the project's shared directory using the correct path - brief_path = ProjectStorageManager.get_brief_path(self.project_id) - brief_path.parent.mkdir(parents=True, exist_ok=True) - write_model(brief_path, brief) + # Write the project to storage using ShareStorage to ensure proper consolidated format + ShareStorage.write_share(self.share_id, project) async def test_get_project_brief(self) -> None: """Test that get_project_brief correctly loads the brief from storage""" - # Mock the ProjectManager to use our test context - with unittest.mock.patch.object(ProjectManager, "get_project_id", return_value=self.project_id): + # Mock the KnowledgeTransferManager to use our test context + with unittest.mock.patch.object(ShareManager, "get_share_id", return_value=self.share_id): # Using Any here to satisfy type checker with our mock context: Any = self.context - # Get the brief using the ProjectManager - brief = await ProjectManager.get_project_brief(context) - project = ProjectStorage.read_project(self.project_id) + brief = await KnowledgeBriefManager.get_knowledge_brief(context) + project = ShareStorage.read_share(self.share_id) - # Verify the brief was loaded correctly - self.assertIsNotNone(brief, "Should load the brief") + assert brief is not None, "Should load the brief" if brief: # Type checking guard - self.assertEqual(brief.title, self.title) - self.assertEqual(brief.conversation_id, self.conversation_id) + assert brief.title == self.title + assert brief.conversation_id == self.conversation_id # Verify the project goals were loaded correctly - self.assertIsNotNone(project, "Should load the project") + assert project is not None, "Should load the project" if project: # Type checking guard - self.assertEqual(len(project.goals), 1, "Should have one goal") - self.assertEqual(project.goals[0].name, "Test Goal") + assert len(project.learning_objectives) == 1, "Should have one goal" + assert project.learning_objectives[0].name == "Test Goal" async def test_direct_storage_access(self) -> None: """Test direct access to project storage""" - # Test basic storage operations - brief_path = ProjectStorageManager.get_brief_path(self.project_id) - - # Read the brief directly using read_model - brief = read_model(brief_path, ProjectBrief) + # Test basic storage operations with consolidated storage + brief = ShareStorage.read_knowledge_brief(self.share_id) # Verify we got the correct brief - self.assertIsNotNone(brief, "Should load the brief directly") + assert brief is not None, "Should load the brief directly" if brief: # Type checking guard - self.assertEqual(brief.title, self.title) + assert brief.title == self.title - # Test updating the brief - brief.title = "Updated Project Title" - write_model(brief_path, brief) + # Test updating the brief using consolidated storage + brief.title = "Updated KnowledgePackageTitle" + ShareStorage.write_knowledge_brief(self.share_id, brief) # Read it back to verify the update - updated_brief = read_model(brief_path, ProjectBrief) + updated_brief = ShareStorage.read_knowledge_brief(self.share_id) if updated_brief: # Type checking guard - self.assertEqual(updated_brief.title, "Updated Project Title") + assert updated_brief.title == "Updated KnowledgePackageTitle" if __name__ == "__main__": diff --git a/assistants/project-assistant/tests/test_inspector.py b/assistants/project-assistant/tests/test_inspector.py new file mode 100644 index 000000000..3478cb0ed --- /dev/null +++ b/assistants/project-assistant/tests/test_inspector.py @@ -0,0 +1,73 @@ +""" +Test script to verify state inspector functionality. +""" + +import asyncio +import logging +from unittest.mock import AsyncMock, MagicMock + +from assistant.assistant import assistant +from semantic_workbench_api_model.workbench_model import AssistantStateEvent +from semantic_workbench_assistant.assistant_app import ConversationContext + +# Set up logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def test_inspector(): + """Test the state inspector provider.""" + # Create mock context + context = AsyncMock(spec=ConversationContext) + context.id = "test-conversation-id" + context.assistant = MagicMock() + context.assistant.id = "test-assistant-id" + + # Mock conversation + conversation = MagicMock() + conversation.metadata = { + "setup_complete": True, + "assistant_mode": "coordinator", + "share_role": "coordinator", + } + context.get_conversation.return_value = conversation + + # Test all four tabbed inspectors + inspector_ids = [ + "brief", + "objectives", + "requests", + "debug", + ] # Note: "requests" tab now shows as "Sharing" + + for inspector_id in inspector_ids: + logger.info(f"Testing {inspector_id} inspector...") + + # Create state event + state_event = AssistantStateEvent(state_id=inspector_id, event="focus", state=None) + + # Send event + logger.info("Sending state event...") + await context.send_conversation_state_event(state_event) + + # Get inspector provider + inspector_provider = assistant.inspector_state_providers.get(inspector_id) + if not inspector_provider: + logger.error(f"No {inspector_id} inspector provider found!") + continue + + logger.info(f"Inspector provider found: {inspector_provider.display_name}") + + # Get state data + try: + state_data = await inspector_provider.get(context) + logger.info(f"State data: {state_data}") + except Exception as e: + logger.error(f"Error getting state data: {e}") + + logger.info(f"--- {inspector_id} inspector test completed ---") + + +# Run the test +if __name__ == "__main__": + asyncio.run(test_inspector()) diff --git a/assistants/project-assistant/tests/test_project_manager.py b/assistants/project-assistant/tests/test_project_manager.py deleted file mode 100644 index 119ce315c..000000000 --- a/assistants/project-assistant/tests/test_project_manager.py +++ /dev/null @@ -1,214 +0,0 @@ -""" -Tests for the ProjectManager functionality. -""" - -from unittest.mock import AsyncMock, MagicMock - -import pytest -from assistant.project_manager import ProjectManager -from assistant.project_data import Project, ProjectInfo, ProjectGoal, SuccessCriterion, ProjectState -from semantic_workbench_assistant.assistant_app import ConversationContext - - -class TestProjectManager: - """Test the ProjectManager class.""" - - @pytest.fixture - def context(self): - """Set up test fixtures.""" - context = AsyncMock(spec=ConversationContext) - context.conversation = MagicMock() - context.id = "test-conversation-id" - return context - - @pytest.mark.asyncio - async def test_delete_project_goal(self, context, monkeypatch): - """Test the delete_project_goal method in ProjectManager.""" - # Setup test data - project_id = "test-project-id" - goal_index = 1 - goal_name = "Test Goal" - goal_description = "Test Description" - - # Create a test project with multiple goals - test_project = Project( - info=None, - brief=None, - goals=[ - ProjectGoal(name="Goal 1", description="Description 1", priority=1, success_criteria=[]), - ProjectGoal(name=goal_name, description=goal_description, priority=2, success_criteria=[ - SuccessCriterion(description="Criterion 1"), - SuccessCriterion(description="Criterion 2", completed=True), - ]), - ProjectGoal(name="Goal 3", description="Description 3", priority=3, success_criteria=[]), - ], - whiteboard=None, - requests=[], - ) - - # Create test project info - test_project_info = ProjectInfo( - project_id=project_id, - coordinator_conversation_id="test-coordinator-id", - completed_criteria=1, - total_criteria=2, - progress_percentage=50, - version=1, - state=ProjectState.PLANNING - ) - - # Mock get_project_id - async def mock_get_project_id(*args, **kwargs): - return project_id - monkeypatch.setattr("assistant.project_manager.ProjectManager.get_project_id", - AsyncMock(side_effect=mock_get_project_id)) - - # Mock require_current_user - async def mock_require_current_user(*args, **kwargs): - return "test-user-id" - monkeypatch.setattr("assistant.project_manager.require_current_user", - AsyncMock(side_effect=mock_require_current_user)) - - # Mock read_project - def mock_read_project(proj_id): - assert proj_id == project_id - return test_project - monkeypatch.setattr("assistant.project_manager.ProjectStorage.read_project", - MagicMock(side_effect=mock_read_project)) - - # Mock read_project_info - def mock_read_project_info(proj_id): - assert proj_id == project_id - return test_project_info - monkeypatch.setattr("assistant.project_manager.ProjectStorage.read_project_info", - MagicMock(side_effect=mock_read_project_info)) - - # Track if write_project and write_project_info were called with correct arguments - write_project_called = False - write_project_info_called = False - - # Mock write_project - def mock_write_project(proj_id, project): - nonlocal write_project_called - assert proj_id == project_id - # Verify goal was removed - assert len(project.goals) == 2 - assert project.goals[0].name == "Goal 1" - assert project.goals[1].name == "Goal 3" - write_project_called = True - monkeypatch.setattr("assistant.project_manager.ProjectStorage.write_project", - MagicMock(side_effect=mock_write_project)) - - # Mock write_project_info - def mock_write_project_info(proj_id, project_info): - nonlocal write_project_info_called - assert proj_id == project_id - # Verify project info was updated - assert project_info.completed_criteria == 0 # Completed criterion was in the deleted goal - assert project_info.total_criteria == 0 # All criteria were in the deleted goal - assert project_info.progress_percentage == 0 - assert project_info.version == 2 # Incremented - write_project_info_called = True - monkeypatch.setattr("assistant.project_manager.ProjectStorage.write_project_info", - MagicMock(side_effect=mock_write_project_info)) - - # Mock log_project_event - log_event_called = False - async def mock_log_project_event(*args, **kwargs): - nonlocal log_event_called - log_event_called = True - monkeypatch.setattr("assistant.project_manager.ProjectStorage.log_project_event", - AsyncMock(side_effect=mock_log_project_event)) - - # Mock notify_project_update - notify_called = False - async def mock_notify_project_update(*args, **kwargs): - nonlocal notify_called - notify_called = True - monkeypatch.setattr("assistant.project_manager.ProjectNotifier.notify_project_update", - AsyncMock(side_effect=mock_notify_project_update)) - - # Mock refresh_all_project_uis - refresh_called = False - async def mock_refresh_all_project_uis(*args, **kwargs): - nonlocal refresh_called - refresh_called = True - monkeypatch.setattr("assistant.project_manager.ProjectStorage.refresh_all_project_uis", - AsyncMock(side_effect=mock_refresh_all_project_uis)) - - # Call the method being tested - success, goal_name_result = await ProjectManager.delete_project_goal(context, goal_index) - - # Verify the result - assert success is True - assert goal_name_result == goal_name - - # Verify all the expected actions were performed - assert write_project_called - assert write_project_info_called - assert log_event_called - assert notify_called - assert refresh_called - - @pytest.mark.asyncio - async def test_delete_project_goal_invalid_index(self, context, monkeypatch): - """Test deleting a goal with an invalid index.""" - # Setup - project_id = "test-project-id" - goal_index = 5 # Out of range - - # Create a test project with fewer goals than the index - test_project = Project( - info=None, - brief=None, - goals=[ - ProjectGoal(name="Goal 1", description="Description 1", priority=1, success_criteria=[]), - ProjectGoal(name="Goal 2", description="Description 2", priority=2, success_criteria=[]), - ], - whiteboard=None, - requests=[], - ) - - # Mock get_project_id - async def mock_get_project_id(*args, **kwargs): - return project_id - monkeypatch.setattr("assistant.project_manager.ProjectManager.get_project_id", - AsyncMock(side_effect=mock_get_project_id)) - - # Mock require_current_user - async def mock_require_current_user(*args, **kwargs): - return "test-user-id" - monkeypatch.setattr("assistant.project_manager.require_current_user", - AsyncMock(side_effect=mock_require_current_user)) - - # Mock read_project - def mock_read_project(proj_id): - assert proj_id == project_id - return test_project - monkeypatch.setattr("assistant.project_manager.ProjectStorage.read_project", - MagicMock(side_effect=mock_read_project)) - - # Call the method being tested with an invalid index - success, error_message = await ProjectManager.delete_project_goal(context, goal_index) - - # Verify the result indicates failure with appropriate error message - assert success is False - assert error_message is not None - assert "Invalid goal index" in str(error_message) - - @pytest.mark.asyncio - async def test_delete_project_goal_no_project(self, context, monkeypatch): - """Test deleting a goal when no project is associated with the conversation.""" - # Mock get_project_id to return None - async def mock_get_project_id(*args, **kwargs): - return None - monkeypatch.setattr("assistant.project_manager.ProjectManager.get_project_id", - AsyncMock(side_effect=mock_get_project_id)) - - # Call the method being tested - success, error_message = await ProjectManager.delete_project_goal(context, 1) - - # Verify the result indicates failure with appropriate error message - assert success is False - assert error_message is not None - assert "No project associated with this conversation" in str(error_message) \ No newline at end of file diff --git a/assistants/project-assistant/tests/test_project_storage.py b/assistants/project-assistant/tests/test_project_storage.py deleted file mode 100644 index adafe60c6..000000000 --- a/assistants/project-assistant/tests/test_project_storage.py +++ /dev/null @@ -1,478 +0,0 @@ -""" -Tests for the direct project storage functionality. -""" - -import pathlib -import shutil -import unittest -import unittest.mock -import uuid -from datetime import datetime - -from assistant.conversation_project_link import ConversationProjectManager -from assistant.project_data import ( - InformationRequest, - LogEntry, - LogEntryType, - Project, - ProjectBrief, - ProjectGoal, - ProjectInfo, - ProjectLog, - ProjectWhiteboard, - RequestPriority, - RequestStatus, - SuccessCriterion, -) -from assistant.project_storage import ProjectStorage, ProjectStorageManager -from assistant.project_storage_models import ( - ConversationRole, - CoordinatorConversationMessage, - CoordinatorConversationStorage, -) -from semantic_workbench_api_model.workbench_model import AssistantStateEvent -from semantic_workbench_assistant import settings -from semantic_workbench_assistant.storage import write_model - - -class TestProjectStorage(unittest.IsolatedAsyncioTestCase): - """Test the direct project storage functionality.""" - - async def asyncSetUp(self): - """Set up test environment.""" - # Create a test directory - self.test_dir = pathlib.Path(__file__).parent.parent / ".data" / "test_project_storage" - self.test_dir.mkdir(parents=True, exist_ok=True) - - # Mock settings to use our test directory - self.original_storage_root = settings.storage.root - settings.storage.root = str(self.test_dir) - - # Create test IDs - self.project_id = str(uuid.uuid4()) - self.conversation_id = str(uuid.uuid4()) - self.user_id = "test-user-id" - - # Create project directory structure - self.project_dir = ProjectStorageManager.get_project_dir(self.project_id) - - # Set up directories for different conversation roles - self.coordinator_dir = self.project_dir / ConversationRole.COORDINATOR.value - self.coordinator_dir.mkdir(exist_ok=True) - - self.team_dir = self.project_dir / f"team_{self.conversation_id}" - self.team_dir.mkdir(exist_ok=True) - - # Set up patching - self.patches = [] - - # Create a mock context - self.context = unittest.mock.MagicMock() - self.context.id = self.conversation_id - - # Mock assistant - mock_assistant = unittest.mock.MagicMock() - mock_assistant.id = "test-assistant-id" - self.context.assistant = mock_assistant - - # Mock send_conversation_state_event - self.context.send_conversation_state_event = unittest.mock.AsyncMock() - - # Mock get_participants with the correct structure - participants_mock = unittest.mock.MagicMock() - participants_mock.participants = [] - self.context.get_participants = unittest.mock.AsyncMock(return_value=participants_mock) - - # Patch storage_directory_for_context - def mock_storage_directory_for_context(context, *args, **kwargs): - return self.test_dir / f"context_{context.id}" - - patch1 = unittest.mock.patch( - "assistant.project_storage.storage_directory_for_context", side_effect=mock_storage_directory_for_context - ) - self.mock_storage_directory = patch1.start() - self.patches.append(patch1) - - # Create initial test data - self.create_test_project_data() - - return None - - async def asyncTearDown(self): - """Clean up test environment.""" - # Clean up the test directory - if self.test_dir.exists(): - shutil.rmtree(self.test_dir) - - # Restore settings - settings.storage.root = self.original_storage_root - - # Stop all patches - for patch in self.patches: - patch.stop() - - def create_test_project_data(self): - """Create test project data.""" - # Create a project brief - test_goal = ProjectGoal( - name="Test Goal", - description="This is a test goal", - success_criteria=[SuccessCriterion(description="Test criterion")], - ) - - brief = ProjectBrief( - title="Test Project", - description="Test project description", - created_by=self.user_id, - updated_by=self.user_id, - conversation_id=self.conversation_id, - ) - - # Create a Project with the goal - project = Project( - info=None, - brief=brief, - goals=[test_goal], - whiteboard=None, - ) - - # Write the project to storage - project_path = ProjectStorageManager.get_project_path(self.project_id) - project_path.parent.mkdir(parents=True, exist_ok=True) - write_model(project_path, project) - - # Write brief to the proper path using ProjectStorage - brief_path = ProjectStorageManager.get_brief_path(self.project_id) - brief_path.parent.mkdir(parents=True, exist_ok=True) - write_model(brief_path, brief) - - # Create project info - project_info = ProjectInfo( - project_id=self.project_id, - coordinator_conversation_id=self.conversation_id, - ) - project_info_path = ProjectStorageManager.get_project_info_path(self.project_id) - write_model(project_info_path, project_info) - - # Create an information request - request = InformationRequest( - request_id=str(uuid.uuid4()), - title="Test Request", - description="This is a test request", - priority=RequestPriority.HIGH, - status=RequestStatus.NEW, # Use enum value - created_by=self.user_id, - updated_by=self.user_id, - conversation_id=self.conversation_id, - ) - - # Write request to the proper path using ProjectStorage - request_path = ProjectStorageManager.get_information_request_path(self.project_id, request.request_id) - request_path.parent.mkdir(parents=True, exist_ok=True) - write_model(request_path, request) - - # Create context directories - context_dir = self.test_dir / f"context_{self.conversation_id}" - context_dir.mkdir(exist_ok=True, parents=True) - - async def test_read_project_brief(self): - """Test reading a project brief.""" - # Read the brief using ProjectStorage - brief = ProjectStorage.read_project_brief(self.project_id) - project = ProjectStorage.read_project(self.project_id) - - # Verify the brief was loaded correctly - self.assertIsNotNone(brief, "Should load the brief") - if brief: # Type checking guard - self.assertEqual(brief.title, "Test Project") - self.assertEqual(brief.description, "Test project description") - - # Verify the project was loaded with goals correctly - self.assertIsNotNone(project, "Should load the project") - if project: # Type checking guard - self.assertEqual(len(project.goals), 1) - self.assertEqual(project.goals[0].name, "Test Goal") - - async def test_read_information_request(self): - """Test reading an information request.""" - # First get all requests to find the request ID - requests = ProjectStorage.get_all_information_requests(self.project_id) - self.assertEqual(len(requests), 1, "Should find one request") - request_id = requests[0].request_id - - # Read the request using ProjectStorage - request = ProjectStorage.read_information_request(self.project_id, request_id) - - # Verify the request was loaded correctly - self.assertIsNotNone(request, "Should load the request") - if request: # Type checking guard - self.assertEqual(request.title, "Test Request") - self.assertEqual(request.description, "This is a test request") - self.assertEqual(request.priority, RequestPriority.HIGH) - - async def test_write_project_log(self): - """Test writing a project log.""" - # Create a log entry and proper LogEntry objects - log_entry = ProjectLog( - entries=[ - LogEntry( - id=str(uuid.uuid4()), - timestamp=datetime.utcnow(), - entry_type=LogEntryType.INFORMATION_UPDATE, - message="Test log entry", - user_id=self.user_id, - user_name="Test User", - ) - ], - ) - - # Write the log - ProjectStorage.write_project_log(self.project_id, log_entry) - - # Read the log back - log = ProjectStorage.read_project_log(self.project_id) - - # Verify the log was saved and loaded correctly - self.assertIsNotNone(log, "Should load the log") - if log: # Type checking guard - self.assertEqual(len(log.entries), 1) - self.assertEqual(log.entries[0].entry_type, LogEntryType.INFORMATION_UPDATE) - self.assertEqual(log.entries[0].message, "Test log entry") - - async def test_project_directory_structure(self): - """Test the project directory structure.""" - # Verify project directory exists - self.assertTrue(self.project_dir.exists(), "Project directory should exist") - - # Verify Coordinator directory exists - self.assertTrue(self.coordinator_dir.exists(), "Coordinator directory should exist") - - # Verify team directory exists - self.assertTrue(self.team_dir.exists(), "Team directory should exist") - - async def test_coordinator_conversation_storage(self): - """Test the coordinator conversation storage functionality.""" - # Create coordinator conversation storage - messages = [ - CoordinatorConversationMessage( - message_id=str(uuid.uuid4()), - content="Test message 1", - sender_name="Test User", - is_assistant=False, - ), - CoordinatorConversationMessage( - message_id=str(uuid.uuid4()), - content="Test message 2", - sender_name="Test Assistant", - is_assistant=True, - ), - ] - - conv_storage = CoordinatorConversationStorage( - project_id=self.project_id, - messages=messages, - ) - - # Write to storage - ProjectStorage.write_coordinator_conversation(self.project_id, conv_storage) - - # Read back - read_storage = ProjectStorage.read_coordinator_conversation(self.project_id) - - # Verify data was saved correctly - self.assertIsNotNone(read_storage, "Should load the coordinator conversation") - if read_storage: - self.assertEqual(read_storage.project_id, self.project_id) - self.assertEqual(len(read_storage.messages), 2) - self.assertEqual(read_storage.messages[0].content, "Test message 1") - self.assertEqual(read_storage.messages[1].content, "Test message 2") - self.assertFalse(read_storage.messages[0].is_assistant) - self.assertTrue(read_storage.messages[1].is_assistant) - - async def test_append_coordinator_message(self): - """Test appending a message to coordinator conversation storage.""" - # Start with empty storage - ProjectStorage.append_coordinator_message( - project_id=self.project_id, - message_id=str(uuid.uuid4()), - content="First message", - sender_name="Test User", - ) - - # Append another message - ProjectStorage.append_coordinator_message( - project_id=self.project_id, - message_id=str(uuid.uuid4()), - content="Second message", - sender_name="Test Assistant", - is_assistant=True, - ) - - # Read back - storage = ProjectStorage.read_coordinator_conversation(self.project_id) - - # Verify messages were added - self.assertIsNotNone(storage, "Should create and load the coordinator conversation") - if storage: - self.assertEqual(len(storage.messages), 2) - self.assertEqual(storage.messages[0].content, "First message") - self.assertEqual(storage.messages[1].content, "Second message") - self.assertFalse(storage.messages[0].is_assistant) - self.assertTrue(storage.messages[1].is_assistant) - - async def test_message_limit_in_coordinator_conversation(self): - """Test that coordinator conversation storage limits to the most recent messages.""" - # Add more than 50 messages - for i in range(60): - ProjectStorage.append_coordinator_message( - project_id=self.project_id, - message_id=str(uuid.uuid4()), - content=f"Message {i + 1}", - sender_name="Test User", - ) - - # Read back - storage = ProjectStorage.read_coordinator_conversation(self.project_id) - - # Verify only the most recent 50 messages are kept - self.assertIsNotNone(storage, "Should load the coordinator conversation") - if storage: - self.assertEqual(len(storage.messages), 50, "Should limit to 50 messages") - # First message should be the 11th message (since we keep the last 50 of 60) - self.assertEqual(storage.messages[0].content, "Message 11") - # Last message should be the 60th message - self.assertEqual(storage.messages[49].content, "Message 60") - - async def test_project_whiteboard(self): - """Test reading and writing project whiteboard.""" - # Create whiteboard - whiteboard = ProjectWhiteboard( - content="# Test Whiteboard\n\nThis is a test whiteboard.", - is_auto_generated=True, - created_by=self.user_id, - updated_by=self.user_id, - conversation_id=self.conversation_id, - ) - - # Write whiteboard - ProjectStorage.write_project_whiteboard(self.project_id, whiteboard) - - # Read whiteboard - read_whiteboard = ProjectStorage.read_project_whiteboard(self.project_id) - - # Verify whiteboard was saved correctly - self.assertIsNotNone(read_whiteboard, "Should load the whiteboard") - if read_whiteboard: - self.assertEqual(read_whiteboard.content, "# Test Whiteboard\n\nThis is a test whiteboard.") - self.assertTrue(read_whiteboard.is_auto_generated) - - async def test_refresh_current_ui(self): - """Test refreshing the current UI inspector.""" - # Call refresh_current_ui - await ProjectStorage.refresh_current_ui(self.context) - - # Verify that send_conversation_state_event was called with correct parameters - self.context.send_conversation_state_event.assert_called_once() - called_event = self.context.send_conversation_state_event.call_args[0][0] - self.assertIsInstance(called_event, AssistantStateEvent) - self.assertEqual(called_event.state_id, "project_status") - self.assertEqual(called_event.event, "updated") - self.assertIsNone(called_event.state) - - async def test_project_info(self): - """Test reading and writing project info.""" - # Read existing project info - project_info = ProjectStorage.read_project_info(self.project_id) - - # Verify it was loaded correctly - self.assertIsNotNone(project_info, "Should load project info") - if project_info: - self.assertEqual(project_info.project_id, self.project_id) - self.assertEqual(project_info.coordinator_conversation_id, self.conversation_id) - - # Update project info - if project_info: - project_info.status_message = "Test status message" - project_info.progress_percentage = 50 - project_info.next_actions = ["Action 1", "Action 2"] - - # Write updated project info - ProjectStorage.write_project_info(self.project_id, project_info) - - # Read updated project info - updated_info = ProjectStorage.read_project_info(self.project_id) - - # Verify updates were saved - self.assertIsNotNone(updated_info, "Should load updated project info") - if updated_info: - self.assertEqual(updated_info.status_message, "Test status message") - self.assertEqual(updated_info.progress_percentage, 50) - self.assertEqual(updated_info.next_actions, ["Action 1", "Action 2"]) - - async def test_get_linked_conversations_dir(self): - """Test getting linked conversations directory.""" - # Get linked conversations directory - linked_dir = ProjectStorageManager.get_linked_conversations_dir(self.project_id) - - # Verify directory exists - self.assertTrue(linked_dir.exists(), "Linked conversations directory should exist") - self.assertEqual(linked_dir.name, "linked_conversations") - - async def test_conversation_association(self): - """Test conversation association with project.""" - # Mock ConversationProjectManager.associate_conversation_with_project - with unittest.mock.patch("assistant.conversation_project_link.write_model") as mock_write_model: - # Mock conversation project path - conversation_project_file = ProjectStorageManager.get_conversation_project_file_path(self.context) - - # Call associate_conversation_with_project - await ConversationProjectManager.associate_conversation_with_project(self.context, self.project_id) - - # Verify write_model was called - mock_write_model.assert_called_once() - - # Verify the file path in the call - call_args = mock_write_model.call_args[0] - self.assertEqual(call_args[0], conversation_project_file) - - # Verify the ProjectAssociation object created - self.assertEqual(call_args[1].project_id, self.project_id) - - async def test_log_project_event(self): - """Test logging a project event.""" - - # Create a test log entry directly - log_entry = LogEntry( - entry_type=LogEntryType.INFORMATION_UPDATE, - message="Test direct log entry", - user_id=self.user_id, - user_name="Test User", - related_entity_id="test-entity-id", - metadata={"test": "metadata"}, - ) - - # Create a log with the entry - log = ProjectLog(entries=[log_entry]) - - # Write the log directly - ProjectStorage.write_project_log(self.project_id, log) - - # Read the log back - read_log = ProjectStorage.read_project_log(self.project_id) - self.assertIsNotNone(read_log, "Should load the log") - if read_log: - # Find our test entry - found_entry = False - for entry in read_log.entries: - if entry.message == "Test direct log entry": - found_entry = True - self.assertEqual(entry.entry_type, LogEntryType.INFORMATION_UPDATE) - self.assertEqual(entry.user_id, self.user_id) - self.assertEqual(entry.user_name, "Test User") - self.assertEqual(entry.related_entity_id, "test-entity-id") - self.assertEqual(entry.metadata, {"test": "metadata"}) - self.assertTrue(found_entry, "Should find the added log entry") - - -if __name__ == "__main__": - unittest.main() diff --git a/assistants/project-assistant/tests/test_project_tools.py b/assistants/project-assistant/tests/test_project_tools.py deleted file mode 100644 index f4036f3cc..000000000 --- a/assistants/project-assistant/tests/test_project_tools.py +++ /dev/null @@ -1,308 +0,0 @@ -""" -Tests for the ProjectTools functionality. -""" - -import contextlib -from unittest.mock import AsyncMock, MagicMock - -import openai_client -import pytest -from assistant.project_analysis import detect_information_request_needs -from assistant.project_storage_models import ConversationRole -from assistant.tools import ProjectTools -from semantic_workbench_assistant.assistant_app import ConversationContext - - -# Use pytest for all tests for consistency -class TestProjectTools: - """Test the ProjectTools class.""" - - @pytest.fixture - def context(self): - """Set up test fixtures.""" - context = AsyncMock(spec=ConversationContext) - context.conversation = MagicMock() - context.id = "test-conversation-id" - # Add the assistant attribute for the get_project_tools test - context.assistant = MagicMock() - # Use the correct property name (_template_id) - from assistant.utils import DEFAULT_TEMPLATE_ID - context.assistant._template_id = DEFAULT_TEMPLATE_ID - return context - - def test_initialization(self, context): - """Test that ProjectTools initializes correctly.""" - # Test Coordinator role - coordinator_tools = ProjectTools(context, ConversationRole.COORDINATOR) - assert coordinator_tools.role == ConversationRole.COORDINATOR - assert coordinator_tools.tool_functions is not None - - # Verify Coordinator-specific functions are registered - assert "update_brief" in coordinator_tools.tool_functions.function_map - assert "add_project_goal" in coordinator_tools.tool_functions.function_map - assert "delete_project_goal" in coordinator_tools.tool_functions.function_map - assert "resolve_information_request" in coordinator_tools.tool_functions.function_map - assert "mark_project_ready_for_working" in coordinator_tools.tool_functions.function_map - - # Verify Team-specific functions are NOT registered - assert "create_information_request" not in coordinator_tools.tool_functions.function_map - assert "update_project_status" not in coordinator_tools.tool_functions.function_map - assert "mark_criterion_completed" not in coordinator_tools.tool_functions.function_map - assert "report_project_completion" not in coordinator_tools.tool_functions.function_map - - # Test Team role - team_tools = ProjectTools(context, ConversationRole.TEAM) - assert team_tools.role == ConversationRole.TEAM - assert team_tools.tool_functions is not None - - # Verify Team-specific functions are registered - assert "create_information_request" in team_tools.tool_functions.function_map - assert "update_project_status" in team_tools.tool_functions.function_map # Updated to match implementation - assert "mark_criterion_completed" in team_tools.tool_functions.function_map - assert "report_project_completion" in team_tools.tool_functions.function_map - assert "delete_information_request" in team_tools.tool_functions.function_map # Added new function - - # Verify Coordinator-specific functions are NOT registered - assert "create_project_brief" not in team_tools.tool_functions.function_map - assert "add_project_goal" not in team_tools.tool_functions.function_map - assert "resolve_information_request" not in team_tools.tool_functions.function_map - assert "mark_project_ready_for_working" not in team_tools.tool_functions.function_map - - # Verify common functions are registered for both roles - assert "suggest_next_action" in coordinator_tools.tool_functions.function_map - - # Verify team detection tool is not in Coordinator tools - assert "detect_information_request_needs" not in coordinator_tools.tool_functions.function_map - - assert "suggest_next_action" in team_tools.tool_functions.function_map - - # detect_information_request_needs is not exposed as a tool function anymore - assert "detect_information_request_needs" not in team_tools.tool_functions.function_map - - @pytest.mark.asyncio - async def test_project_tools_with_config(self, context, monkeypatch): - """Test the ProjectTools behavior with different configurations.""" - # Mock the assistant_config.get method - mock_config = MagicMock() - mock_config.track_progress = True - - async def mock_get_config(*args, **kwargs): - return mock_config - - # Patch the assistant_config.get method - mock_assistant_config = MagicMock() - mock_assistant_config.get = AsyncMock(side_effect=mock_get_config) - monkeypatch.setattr("assistant.config.assistant_config", mock_assistant_config) - - # Test with track_progress set to True first - # Create a ProjectTools instance directly - tools = ProjectTools(context, ConversationRole.COORDINATOR) - - # Make sure add_project_goal was added when track_progress=True - assert "add_project_goal" in tools.tool_functions.function_map - - # For team role, check criterion completion - team_tools = ProjectTools(context, ConversationRole.TEAM) - assert "mark_criterion_completed" in team_tools.tool_functions.function_map - - # Now test with track_progress set to False - mock_config.track_progress = False - - # Test with get_project_tools which handles tool removal based on track_progress - # Since the track_progress check is now done in get_project_tools, we need to test that function - - # Create our own implementation to check for track_progress - async def check_tools_with_config(context, role): - """Simple wrapper to test if tools are filtered based on track_progress.""" - tools = ProjectTools(context, role) - - # If progress tracking is disabled, remove progress-related tools - if not mock_config.track_progress: - # List of progress-related functions to remove - progress_functions = [ - "add_project_goal", - "delete_project_goal", - "mark_criterion_completed", - "mark_project_ready_for_working", - "report_project_completion", - ] - - # Remove progress-related functions - for func_name in progress_functions: - if func_name in tools.tool_functions.function_map: - del tools.tool_functions.function_map[func_name] - - return tools - - # Get the tools using our function that checks track_progress - project_tools = await check_tools_with_config(context, ConversationRole.COORDINATOR) - - # Verify progress-tracking tools are removed when track_progress=False - assert "add_project_goal" not in project_tools.tool_functions.function_map - assert "mark_project_ready_for_working" not in project_tools.tool_functions.function_map - - # For team tools - team_tools = await check_tools_with_config(context, ConversationRole.TEAM) - assert "mark_criterion_completed" not in team_tools.tool_functions.function_map - assert "report_project_completion" not in team_tools.tool_functions.function_map - - @pytest.mark.asyncio - async def test_detect_information_request_needs(self, context, monkeypatch): - """Test the detect_information_request_needs function.""" - # Create a more complete context mock for this test - context.assistant = MagicMock() - context.assistant._template_id = "default" - context.assistant.id = "test-assistant-id" - - # Test message - test_message = "I need information about how to proceed with this task." - - # Setup mock config to be returned from assistant_config.get - mock_config = MagicMock() - mock_config.track_progress = True - mock_config.service_config = None # Will cause the method to return early with error info - - async def mock_get_config(*args, **kwargs): - return mock_config - - # Patch assistant_config.get - mock_assistant_config = MagicMock() - mock_assistant_config.get = AsyncMock(side_effect=mock_get_config) - monkeypatch.setattr("assistant.project_analysis.assistant_config", mock_assistant_config) - - # Create a mock message for the message history - mock_msg = MagicMock() - mock_msg.sender = MagicMock() - mock_msg.sender.participant_id = "test-user-id" # Not the assistant ID - mock_msg.content = "Test message content" - - # Mock get_messages response - mock_messages_response = MagicMock() - mock_messages_response.messages = [mock_msg] - context.get_messages = AsyncMock(return_value=mock_messages_response) - - # Test with the message - should return early with missing service_config - result = await detect_information_request_needs(context, test_message) - - # Verify we get the expected early-return response for missing service_config - assert not result["is_information_request"] - assert "LLM detection unavailable" in result["reason"] - assert result["confidence"] == 0.0 - - # Now update mock config with a service_config and simulate a successful LLM response - mock_config.service_config = {"type": "openai"} - - # Create mock client that returns expected response - mock_client = AsyncMock() - mock_response = MagicMock() - mock_choice = MagicMock() - mock_message = MagicMock() - mock_message.content = '{"is_information_request": true, "confidence": 0.9, "potential_title": "Test title"}' - mock_choice.message = mock_message - mock_response.choices = [mock_choice] - mock_client.chat.completions.create = AsyncMock(return_value=mock_response) - - # Mock the client creation context manager - @contextlib.asynccontextmanager - async def mock_create_client(*args, **kwargs): - try: - yield mock_client - finally: - pass - - # Patch the openai_client.create_client context manager - monkeypatch.setattr(openai_client, "create_client", mock_create_client) - - # Test with message that should return mocked success response - result = await detect_information_request_needs(context, test_message) - - # Verify successful path results - assert result["is_information_request"] is True - assert result["confidence"] == 0.9 - assert result["potential_title"] == "Test title" - assert result["original_message"] == test_message - - @pytest.mark.asyncio - async def test_delete_project_goal(self, context, monkeypatch): - """Test the delete_project_goal functionality.""" - # Create ProjectTools instance for Coordinator role - tools = ProjectTools(context, ConversationRole.COORDINATOR) - - # Setup mocks - project_id = "test-project-id" - goal_index = 1 - goal_name = "Test Goal" - - # Mock ProjectManager.get_project_id - async def mock_get_project_id(*args, **kwargs): - return project_id - monkeypatch.setattr("assistant.project_manager.ProjectManager.get_project_id", - AsyncMock(side_effect=mock_get_project_id)) - - # Mock require_current_user to return a user ID - async def mock_require_current_user(*args, **kwargs): - return "test-user-id" - monkeypatch.setattr("assistant.project_manager.require_current_user", - AsyncMock(side_effect=mock_require_current_user)) - - # Mock ProjectManager.delete_project_goal to return success - async def mock_delete_project_goal(*args, **kwargs): - return True, goal_name - monkeypatch.setattr("assistant.project_manager.ProjectManager.delete_project_goal", - AsyncMock(side_effect=mock_delete_project_goal)) - - # Test the delete_project_goal function - result = await tools.delete_project_goal(goal_index) - - # Verify the result - assert f"Goal '{goal_name}' has been successfully deleted from the project." in result - - # Verify that context.send_messages was called with appropriate message - expected_message_content = f"Goal '{goal_name}' has been successfully deleted from the project." - context.send_messages.assert_called_once() - # Get the first positional argument passed to send_messages - call_args = context.send_messages.call_args[0][0] - assert call_args.content == expected_message_content - - @pytest.mark.asyncio - async def test_delete_project_goal_wrong_role(self, context): - """Test delete_project_goal with wrong role (Team instead of Coordinator).""" - # Create ProjectTools instance for Team role - tools = ProjectTools(context, ConversationRole.TEAM) - - # Test the delete_project_goal function with Team role - result = await tools.delete_project_goal(1) - - # Verify that the operation is rejected - assert "Only Coordinator can delete project goals." in result - # Verify context.send_messages was not called - context.send_messages.assert_not_called() - - @pytest.mark.asyncio - async def test_delete_project_goal_error(self, context, monkeypatch): - """Test delete_project_goal with error condition.""" - # Create ProjectTools instance for Coordinator role - tools = ProjectTools(context, ConversationRole.COORDINATOR) - - # Setup mocks - error_message = "Invalid goal index" - - # Mock ProjectManager.get_project_id - async def mock_get_project_id(*args, **kwargs): - return "test-project-id" - monkeypatch.setattr("assistant.project_manager.ProjectManager.get_project_id", - AsyncMock(side_effect=mock_get_project_id)) - - # Mock ProjectManager.delete_project_goal to return failure - async def mock_delete_project_goal(*args, **kwargs): - return False, error_message - monkeypatch.setattr("assistant.project_manager.ProjectManager.delete_project_goal", - AsyncMock(side_effect=mock_delete_project_goal)) - - # Test the delete_project_goal function - result = await tools.delete_project_goal(999) # Using an invalid index - - # Verify the error result - assert f"Error deleting goal: {error_message}" in result - # Verify context.send_messages was not called - context.send_messages.assert_not_called() diff --git a/assistants/project-assistant/tests/test_share_manager.py b/assistants/project-assistant/tests/test_share_manager.py new file mode 100644 index 000000000..4ad7eaaab --- /dev/null +++ b/assistants/project-assistant/tests/test_share_manager.py @@ -0,0 +1,295 @@ +""" +Tests for the KnowledgeTransferManager functionality. +""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from assistant.data import ( + LearningObjective, + LearningOutcome, + Share, +) +from assistant.domain import LearningObjectivesManager +from semantic_workbench_assistant.assistant_app import ConversationContext + + +class TestKnowledgeTransferManager: + """Test the KnowledgeTransferManager class.""" + + @pytest.fixture + def context(self): + """Set up test fixtures.""" + context = AsyncMock(spec=ConversationContext) + context.conversation = MagicMock() + context.id = "test-conversation-id" + return context + + # DISABLED: delete_project_goal functionality has been removed from the codebase + # @pytest.mark.asyncio + async def disabled_test_delete_project_goal(self, context, monkeypatch): + """Test the delete_project_goal method in KnowledgeTransferManager.""" + # Setup test data + project_id = "test-project-id" + objective_index = "test-objective-id-1" + goal_name = "Test Goal" + goal_description = "Test Description" + + # Create a test project with multiple goals + test_project = Share( + share_id=project_id, + brief=None, + learning_objectives=[ + LearningObjective( + name="Goal 1", + description="Description 1", + priority=1, + learning_outcomes=[], + ), + LearningObjective( + name=goal_name, + description=goal_description, + priority=2, + learning_outcomes=[ + LearningOutcome(description="Criterion 1"), + LearningOutcome(description="Criterion 2"), + ], + ), + LearningObjective( + name="Goal 3", + description="Description 3", + priority=3, + learning_outcomes=[], + ), + ], + digest=None, + requests=[], + log=None, + ) + + # Set additional fields on the test project + test_project.coordinator_conversation_id = "test-coordinator-id" + # Note: completion_percentage removed from model + test_project.version = 1 + # transfer_state field has been removed from the data model + + # Mock get_project_id + async def mock_get_project_id(*args, **kwargs): + return project_id + + monkeypatch.setattr( + "assistant.manager.KnowledgeTransferManager.get_project_id", + AsyncMock(side_effect=mock_get_project_id), + ) + + # Mock require_current_user + async def mock_require_current_user(*args, **kwargs): + return "test-user-id" + + monkeypatch.setattr( + "assistant.manager.require_current_user", + AsyncMock(side_effect=mock_require_current_user), + ) + + # Mock read_project + def mock_read_project(proj_id): + assert proj_id == project_id + return test_project + + monkeypatch.setattr( + "assistant.storage.ShareStorage.read_project", + MagicMock(side_effect=mock_read_project), + ) + + # Mock read_share_info (now returns the same project) + def mock_read_share_info(proj_id): + assert proj_id == project_id + return test_project + + monkeypatch.setattr( + "assistant.storage.ShareStorage.read_share_info", + MagicMock(side_effect=mock_read_share_info), + ) + + # Track if write_project and write_project_info were called with correct arguments + write_project_called = False + write_project_info_called = False + + # Mock write_project + def mock_write_project(proj_id, project): + nonlocal write_project_called + assert proj_id == project_id + # Verify goal was removed + assert len(project.learning_objectives) == 2 + assert project.learning_objectives[0].name == "Goal 1" + assert project.learning_objectives[1].name == "Goal 3" + write_project_called = True + + monkeypatch.setattr( + "assistant.project_manager.ShareStorage.write_project", + MagicMock(side_effect=mock_write_project), + ) + + # Mock write_share_info (now same as write_share) + def mock_write_share_info(proj_id, package): + nonlocal write_project_info_called + assert proj_id == project_id + # Verify package was updated + assert package.achieved_outcomes == 0 # Completed criterion was in the deleted goal + assert package.total_outcomes == 0 # All criteria were in the deleted goal + # Note: completion_percentage removed from model + assert package.version == 2 # Incremented + write_project_info_called = True + + monkeypatch.setattr( + "assistant.storage.ShareStorage.write_share_info", + MagicMock(side_effect=mock_write_share_info), + ) + + # Mock log_project_event + log_event_called = False + + async def mock_log_project_event(*args, **kwargs): + nonlocal log_event_called + log_event_called = True + + monkeypatch.setattr( + "assistant.project_manager.ShareStorage.log_project_event", + AsyncMock(side_effect=mock_log_project_event), + ) + + # Mock notify_project_update + notify_called = False + + async def mock_notify_project_update(*args, **kwargs): + nonlocal notify_called + notify_called = True + + monkeypatch.setattr( + "assistant.notifications.Notifications.notify_all", + AsyncMock(side_effect=mock_notify_project_update), + ) + + # Mock refresh_all_project_uis + refresh_called = False + + async def mock_refresh_all_project_uis(*args, **kwargs): + nonlocal refresh_called + refresh_called = True + + monkeypatch.setattr( + "assistant.project_manager.ShareStorage.refresh_all_project_uis", + AsyncMock(side_effect=mock_refresh_all_project_uis), + ) + + # Call the method being tested + ( + success, + goal_name_result, + ) = await LearningObjectivesManager.delete_learning_objective(context, objective_index) + + # Verify the result + assert success is True + assert goal_name_result == goal_name + + # Verify all the expected actions were performed + assert write_project_called + assert write_project_info_called + assert log_event_called + assert notify_called + assert refresh_called + + # DISABLED: delete_project_goal functionality has been removed from the codebase + # @pytest.mark.asyncio + async def disabled_test_delete_project_goal_invalid_index(self, context, monkeypatch): + """Test deleting a goal with an invalid index.""" + # Setup + project_id = "test-project-id" + objective_index = "invalid-objective-id" # Invalid ID + + # Create a test project with fewer goals than the index + test_project = Share( + share_id=project_id, + brief=None, + learning_objectives=[ + LearningObjective( + name="Goal 1", + description="Description 1", + priority=1, + learning_outcomes=[], + ), + LearningObjective( + name="Goal 2", + description="Description 2", + priority=2, + learning_outcomes=[], + ), + ], + digest=None, + requests=[], + log=None, + ) + + # Mock get_project_id + async def mock_get_project_id(*args, **kwargs): + return project_id + + monkeypatch.setattr( + "assistant.project_manager.KnowledgeTransferManager.get_project_id", + AsyncMock(side_effect=mock_get_project_id), + ) + + # Mock require_current_user + async def mock_require_current_user(*args, **kwargs): + return "test-user-id" + + monkeypatch.setattr( + "assistant.project_manager.require_current_user", + AsyncMock(side_effect=mock_require_current_user), + ) + + # Mock read_project + def mock_read_project(proj_id): + assert proj_id == project_id + return test_project + + monkeypatch.setattr( + "assistant.project_manager.ShareStorage.read_project", + MagicMock(side_effect=mock_read_project), + ) + + # Call the method being tested with an invalid index + ( + success, + error_message, + ) = await LearningObjectivesManager.delete_learning_objective(context, objective_index) + + # Verify the result indicates failure with appropriate error message + assert success is False + assert error_message is not None + assert "Invalid goal index" in str(error_message) + + # DISABLED: delete_project_goal functionality has been removed from the codebase + # @pytest.mark.asyncio + async def disabled_test_delete_project_goal_no_project(self, context, monkeypatch): + """Test deleting a goal when no project is associated with the conversation.""" + + # Mock get_project_id to return None + async def mock_get_project_id(*args, **kwargs): + return None + + monkeypatch.setattr( + "assistant.project_manager.KnowledgeTransferManager.get_project_id", + AsyncMock(side_effect=mock_get_project_id), + ) + + # Call the method being tested + ( + success, + error_message, + ) = await LearningObjectivesManager.delete_learning_objective(context, "test-objective-id") + + # Verify the result indicates failure with appropriate error message + assert success is False + assert error_message is not None + assert "No project associated with this conversation" in str(error_message) diff --git a/assistants/project-assistant/tests/test_share_storage.py b/assistants/project-assistant/tests/test_share_storage.py new file mode 100644 index 000000000..1b5550aa4 --- /dev/null +++ b/assistants/project-assistant/tests/test_share_storage.py @@ -0,0 +1,484 @@ +""" +Tests for the direct project storage functionality. +""" + +import pathlib +import shutil +import unittest +import unittest.mock +import uuid +from datetime import datetime + +from assistant.data import ( + ConversationRole, + CoordinatorConversationMessage, + CoordinatorConversationMessages, + InformationRequest, + InspectorTab, + KnowledgeBrief, + KnowledgeDigest, + LearningObjective, + LearningOutcome, + LogEntry, + LogEntryType, + RequestPriority, + RequestStatus, + Share, + ShareLog, +) +from assistant.domain.share_manager import ShareManager +from assistant.notifications import Notifications +from assistant.storage import ShareStorage, ShareStorageManager +from semantic_workbench_api_model.workbench_model import AssistantStateEvent +from semantic_workbench_assistant import settings + + +class TestShareStorage(unittest.IsolatedAsyncioTestCase): + """Test the direct project storage functionality.""" + + async def asyncSetUp(self): + """Set up test environment.""" + # Create a test directory + self.test_dir = pathlib.Path(__file__).parent.parent / ".data" / "test_project_storage" + self.test_dir.mkdir(parents=True, exist_ok=True) + + # Mock settings to use our test directory + self.original_storage_root = settings.storage.root + settings.storage.root = str(self.test_dir) + + # Create test IDs + self.share_id = str(uuid.uuid4()) + self.conversation_id = str(uuid.uuid4()) + self.user_id = "test-user-id" + + # Create project directory structure + self.project_dir = ShareStorageManager.get_share_dir(self.share_id) + + # Set up directories for different conversation roles + self.coordinator_dir = self.project_dir / ConversationRole.COORDINATOR.value + self.coordinator_dir.mkdir(exist_ok=True) + + self.team_dir = self.project_dir / f"team_{self.conversation_id}" + self.team_dir.mkdir(exist_ok=True) + + # Set up patching + self.patches = [] + + # Create a mock context + self.context = unittest.mock.MagicMock() + self.context.id = self.conversation_id + + # Mock assistant + mock_assistant = unittest.mock.MagicMock() + mock_assistant.id = "test-assistant-id" + self.context.assistant = mock_assistant + + # Mock send_conversation_state_event + self.context.send_conversation_state_event = unittest.mock.AsyncMock() + + # Mock get_participants with the correct structure + participants_mock = unittest.mock.MagicMock() + participants_mock.participants = [] + self.context.get_participants = unittest.mock.AsyncMock(return_value=participants_mock) + + # Patch storage_directory_for_context + def mock_storage_directory_for_context(context, *args, **kwargs): + return self.test_dir / f"context_{context.id}" + + patch1 = unittest.mock.patch( + "assistant.storage.storage_directory_for_context", + side_effect=mock_storage_directory_for_context, + ) + self.mock_storage_directory = patch1.start() + self.patches.append(patch1) + + # Create initial test data + self.create_test_project_data() + + return None + + async def asyncTearDown(self): + """Clean up test environment.""" + # Clean up the test directory + if self.test_dir.exists(): + shutil.rmtree(self.test_dir) + + # Restore settings + settings.storage.root = self.original_storage_root + + # Stop all patches + for patch in self.patches: + patch.stop() + + def create_test_project_data(self): + """Create test project data.""" + # Create a project brief + test_goal = LearningObjective( + name="Test Goal", + description="This is a test goal", + learning_outcomes=[LearningOutcome(description="Test criterion")], + ) + + brief = KnowledgeBrief( + title="Test KnowledgePackage", + content="Test project description", + created_by=self.user_id, + updated_by=self.user_id, + conversation_id=self.conversation_id, + ) + + # Create a KnowledgePackage with the goal and brief + project = Share( + share_id=self.share_id, + coordinator_conversation_id=self.conversation_id, + brief=brief, + learning_objectives=[test_goal], + digest=None, + ) + + # Write the project to storage (this now includes the brief and learning objectives) + ShareStorage.write_share(self.share_id, project) + + # Create an information request + request = InformationRequest( + request_id=str(uuid.uuid4()), + title="Test Request", + description="This is a test request", + priority=RequestPriority.HIGH, + status=RequestStatus.NEW, # Use enum value + created_by=self.user_id, + updated_by=self.user_id, + conversation_id=self.conversation_id, + ) + + # Write request using ShareStorage (this will add it to the main share file) + ShareStorage.write_information_request(self.share_id, request) + + # Create context directories + context_dir = self.test_dir / f"context_{self.conversation_id}" + context_dir.mkdir(exist_ok=True, parents=True) + + async def test_read_project_brief(self): + """Test reading a project brief.""" + # Read the brief using ShareStorage + brief = ShareStorage.read_knowledge_brief(self.share_id) + project = ShareStorage.read_share(self.share_id) + + # Verify the brief was loaded correctly + assert brief is not None, "Should load the brief" + if brief: # Type checking guard + assert brief.title == "Test KnowledgePackage" + assert brief.content == "Test project description" + + # Verify the project was loaded with goals correctly + assert project is not None, "Should load the project" + if project: # Type checking guard + assert len(project.learning_objectives) == 1 + assert project.learning_objectives[0].name == "Test Goal" + + async def test_read_information_request(self): + """Test reading an information request.""" + # First get all requests to find the request ID + requests = ShareStorage.get_all_information_requests(self.share_id) + assert len(requests) == 1, "Should find one request" + request_id = requests[0].request_id + + # Read the request using ShareStorage + request = ShareStorage.read_information_request(self.share_id, request_id) + + # Verify the request was loaded correctly + assert request is not None, "Should load the request" + if request: # Type checking guard + assert request.title == "Test Request" + assert request.description == "This is a test request" + assert request.priority == RequestPriority.HIGH + + async def test_write_project_log(self): + """Test writing a project log.""" + # Create a log entry and proper LogEntry objects + log_entry = ShareLog( + entries=[ + LogEntry( + id=str(uuid.uuid4()), + timestamp=datetime.utcnow(), + entry_type=LogEntryType.SHARE_INFORMATION_UPDATE, + message="Test log entry", + user_id=self.user_id, + user_name="Test User", + ) + ], + ) + + # Write the log + ShareStorage.write_share_log(self.share_id, log_entry) + + # Read the log back + log = ShareStorage.read_share_log(self.share_id) + + # Verify the log was saved and loaded correctly + assert log is not None, "Should load the log" + if log: # Type checking guard + assert len(log.entries) == 1 + assert log.entries[0].entry_type == LogEntryType.SHARE_INFORMATION_UPDATE + assert log.entries[0].message == "Test log entry" + + async def test_project_directory_structure(self): + """Test the project directory structure.""" + # Verify project directory exists + assert self.project_dir.exists(), "KnowledgePackage directory should exist" + + # Verify Coordinator directory exists + assert self.coordinator_dir.exists(), "Coordinator directory should exist" + + # Verify team directory exists + assert self.team_dir.exists(), "Team directory should exist" + + async def test_coordinator_conversation_storage(self): + """Test the coordinator conversation storage functionality.""" + # Create coordinator conversation storage + messages = [ + CoordinatorConversationMessage( + message_id=str(uuid.uuid4()), + content="Test message 1", + sender_name="Test User", + is_assistant=False, + ), + CoordinatorConversationMessage( + message_id=str(uuid.uuid4()), + content="Test message 2", + sender_name="Test Assistant", + is_assistant=True, + ), + ] + + conv_storage = CoordinatorConversationMessages( + knowledge_share_id=self.share_id, + messages=messages, + ) + + # Write to storage + ShareStorage.write_coordinator_conversation(self.share_id, conv_storage) + + # Read back + read_storage = ShareStorage.read_coordinator_conversation(self.share_id) + + # Verify data was saved correctly + assert read_storage is not None, "Should load the coordinator conversation" + if read_storage: + assert read_storage.knowledge_share_id == self.share_id + assert len(read_storage.messages) == 2 + assert read_storage.messages[0].content == "Test message 1" + assert read_storage.messages[1].content == "Test message 2" + assert not read_storage.messages[0].is_assistant + assert read_storage.messages[1].is_assistant + + async def test_append_coordinator_message(self): + """Test appending a message to coordinator conversation storage.""" + # Start with empty storage + ShareStorage.append_coordinator_message( + share_id=self.share_id, + message_id=str(uuid.uuid4()), + content="First message", + sender_name="Test User", + ) + + # Append another message + ShareStorage.append_coordinator_message( + share_id=self.share_id, + message_id=str(uuid.uuid4()), + content="Second message", + sender_name="Test Assistant", + is_assistant=True, + ) + + # Read back + storage = ShareStorage.read_coordinator_conversation(self.share_id) + + # Verify messages were added + assert storage is not None, "Should create and load the coordinator conversation" + if storage: + assert len(storage.messages) == 2 + assert storage.messages[0].content == "First message" + assert storage.messages[1].content == "Second message" + assert not storage.messages[0].is_assistant + assert storage.messages[1].is_assistant + + async def test_message_limit_in_coordinator_conversation(self): + """Test that coordinator conversation storage limits to the most recent messages.""" + # Add more than 50 messages + for i in range(60): + ShareStorage.append_coordinator_message( + share_id=self.share_id, + message_id=str(uuid.uuid4()), + content=f"Message {i + 1}", + sender_name="Test User", + ) + + # Read back + storage = ShareStorage.read_coordinator_conversation(self.share_id) + + # Verify only the most recent 50 messages are kept + assert storage is not None, "Should load the coordinator conversation" + if storage: + assert len(storage.messages) == 50, "Should limit to 50 messages" + # First message should be the 11th message (since we keep the last 50 of 60) + assert storage.messages[0].content == "Message 11" + # Last message should be the 60th message + assert storage.messages[49].content == "Message 60" + + async def test_knowledge_digest(self): + """Test reading and writing knowledge digest.""" + # Create knowledge digest + digest = KnowledgeDigest( + content="# Test Knowledge Digest\n\nThis is a test knowledge digest.", + is_auto_generated=True, + created_by=self.user_id, + updated_by=self.user_id, + conversation_id=self.conversation_id, + ) + + # Write knowledge digest (this will add it to the main share file) + ShareStorage.write_knowledge_digest(self.share_id, digest) + + # Read knowledge digest + read_digest = ShareStorage.read_knowledge_digest(self.share_id) + + # Verify knowledge digest was saved correctly + assert read_digest is not None, "Should load the knowledge digest" + if read_digest: + assert read_digest.content == "# Test Knowledge Digest\n\nThis is a test knowledge digest." + assert read_digest.is_auto_generated + + async def test_refresh_current_ui(self): + """Test refreshing the current UI inspector.""" + # Call refresh_current_ui + await Notifications.notify_state_update( + self.context, + [ + InspectorTab.BRIEF, + InspectorTab.LEARNING, + InspectorTab.SHARING, + InspectorTab.DEBUG, + ], + ) + + # Verify that send_conversation_state_event was called 4 times (once per inspector tab) + assert self.context.send_conversation_state_event.call_count == 4 + + # Get all the calls + calls = self.context.send_conversation_state_event.call_args_list + expected_state_ids = ["brief", "learning", "sharing", "debug"] + actual_state_ids = [call[0][0].state_id for call in calls] + + # Verify each call has the correct parameters + for call_args in calls: + called_event = call_args[0][0] + assert isinstance(called_event, AssistantStateEvent) + assert called_event.event == "updated" + assert called_event.state is None + assert called_event.state_id in expected_state_ids + + # Verify all expected state IDs were called + assert set(actual_state_ids) == set(expected_state_ids) + + async def test_knowledge_package_info(self): + """Test reading and writing knowledge package info.""" + # Read existing knowledge package + package = ShareStorage.read_share(self.share_id) + + # Verify it was loaded correctly + assert package is not None, "Should load knowledge package" + if package: + assert package.share_id == self.share_id + + # Update knowledge package info + package.preferred_communication_style = "Test status message" + # Note: completion_percentage removed from model + package.next_learning_actions = ["Action 1", "Action 2"] + + # Write updated knowledge package + ShareStorage.write_share(self.share_id, package) + + # Read updated knowledge package + updated_package = ShareStorage.read_share(self.share_id) + + # Verify updates were saved + assert updated_package is not None, "Should load updated knowledge package" + if updated_package: + assert updated_package.preferred_communication_style == "Test status message" + # Note: completion_percentage removed from model + assert updated_package.next_learning_actions == ["Action 1", "Action 2"] + + async def test_conversation_tracking_in_json(self): + """Test that conversations are tracked in JSON instead of file system.""" + # Load knowledge package + package = ShareStorage.read_share(self.share_id) + assert package is not None + + if package: + # Verify team_conversations dict exists (even if empty) + assert isinstance(package.team_conversations, dict) + + # Verify helper methods work + linked_conversations = await ShareManager.get_linked_conversations(self.context) + assert isinstance(linked_conversations, list) + + async def test_conversation_association(self): + """Test conversation role setting.""" + # Mock ShareManager.set_conversation_role + with unittest.mock.patch("assistant.domain.share_manager.write_model") as mock_write_model: + # Mock conversation role path + conversation_role_file = ShareStorageManager.get_conversation_role_file_path(self.context) + + # Call set_conversation_role + await ShareManager.set_conversation_role(self.context, self.share_id, ConversationRole.COORDINATOR) + + # Verify write_model was called + mock_write_model.assert_called_once() + + # Verify the file path in the call + call_args = mock_write_model.call_args[0] + assert call_args[0] == conversation_role_file + + # Verify the ConversationShareInfo object created + assert call_args[1].share_id == self.share_id + assert call_args[1].role == ConversationRole.COORDINATOR + + async def test_log_project_event(self): + """Test logging a project event.""" + + # Create a test log entry directly + log_entry = LogEntry( + entry_type=LogEntryType.SHARE_INFORMATION_UPDATE, + message="Test direct log entry", + user_id=self.user_id, + user_name="Test User", + related_entity_id="test-entity-id", + metadata={"test": "metadata"}, + ) + + # Create a log with the entry + log = ShareLog(entries=[log_entry]) + + # Write the log directly + ShareStorage.write_share_log(self.share_id, log) + + # Read the log back + read_log = ShareStorage.read_share_log(self.share_id) + assert read_log is not None, "Should load the log" + if read_log: + # Find our test entry + found_entry = False + for entry in read_log.entries: + if entry.message == "Test direct log entry": + found_entry = True + assert entry.entry_type == LogEntryType.SHARE_INFORMATION_UPDATE + assert entry.user_id == self.user_id + assert entry.user_name == "Test User" + assert entry.related_entity_id == "test-entity-id" + assert entry.metadata == {"test": "metadata"} + assert found_entry, "Should find the added log entry" + + +if __name__ == "__main__": + unittest.main() diff --git a/assistants/project-assistant/tests/test_team_mode.py b/assistants/project-assistant/tests/test_team_mode.py index e6b455460..0111cafbb 100644 --- a/assistants/project-assistant/tests/test_team_mode.py +++ b/assistants/project-assistant/tests/test_team_mode.py @@ -102,41 +102,47 @@ def __init__(self, title=None, description=None, priority=None, status=None, **k class ProjectDashboard(BaseArtifact): def __init__( - self, state=None, progress_percentage=0, active_requests=None, completed_criteria=0, total_criteria=0, **kwargs + self, + state=None, + active_requests=None, + completed_criteria=0, + total_criteria=0, + **kwargs, ): super().__init__(artifact_type=ArtifactType.PROJECT_DASHBOARD, **kwargs) + # transfer_state field removed - using derived state logic instead self.state = state or ProjectState.PLANNING - self.progress_percentage = progress_percentage + # Note: completion_percentage removed from model self.active_requests = active_requests or [] - self.completed_criteria = completed_criteria + self.achieved_criteria = completed_criteria self.total_criteria = total_criteria - self.status_message = None + self.transfer_notes = None -class SuccessCriterion: +class LearningOutcome: def __init__(self, id=None, description=None, completed=False, completed_by=None): self.id = id or "test-criterion-id" self.description = description or "Test criterion" - self.completed = completed - self.completed_at = None if not completed else datetime.utcnow() - self.completed_by = completed_by + self.achieved = completed + self.achieved_at = None if not completed else datetime.utcnow() + self.achieved_by = completed_by -class ProjectGoal: +class LearningObjective: def __init__(self, id=None, name=None, description=None, priority=1, success_criteria=None): self.id = id or "test-goal-id" self.name = name or "Test Goal" self.description = description or "Test Goal Description" self.priority = priority - self.success_criteria = success_criteria or [] + self.learning_outcomes = success_criteria or [] -class ProjectBrief(BaseArtifact): +class KnowledgeBrief(BaseArtifact): def __init__(self, title=None, description=None, goals=None, **kwargs): super().__init__(artifact_type=ArtifactType.PROJECT_BRIEF, **kwargs) - self.title = title or "Test Project" + self.title = title or "Test KnowledgePackage" self.description = description or "Test Description" - self.goals = goals or [] + self.learning_objectives = goals or [] # Create a mock for the TeamConversationHandler @@ -185,12 +191,15 @@ async def update_project_info(self, progress_percentage, status_message=None): conversation_id=str(self.context.id), project_id="test-project-id", ) - dashboard.status_message = status_message + dashboard.transfer_notes = status_message # Call mocked log_action for state change and progress update - await self.log_action(LogEntryType.MILESTONE_PASSED, "Project is now in progress") + await self.log_action(LogEntryType.MILESTONE_PASSED, "KnowledgePackage is now in progress") - await self.log_action(LogEntryType.STATUS_CHANGED, f"Updated project progress to {progress_percentage}%") + await self.log_action( + LogEntryType.STATUS_CHANGED, + f"Updated project progress to {progress_percentage}%", + ) # Send notification await self.context.send_messages( @@ -204,10 +213,10 @@ async def update_project_info(self, progress_percentage, status_message=None): async def mark_criterion_completed(self, goal_id, criterion_id): # Mock implementation - criterion = SuccessCriterion(id=criterion_id, description="Test criterion") - criterion.completed = True - criterion.completed_at = datetime.utcnow() - criterion.completed_by = "test-user-id" + criterion = LearningOutcome(id=criterion_id, description="Test criterion") + criterion.achieved = True + criterion.achieved_at = datetime.utcnow() + criterion.achieved_by = "test-user-id" dashboard = ProjectDashboard( state=ProjectState.IN_PROGRESS, @@ -221,7 +230,10 @@ async def mark_criterion_completed(self, goal_id, criterion_id): ) # Call mocked log_action - await self.log_action(LogEntryType.CRITERION_COMPLETED, f"Completed criterion: {criterion.description}") + await self.log_action( + LogEntryType.CRITERION_COMPLETED, + f"Completed criterion: {criterion.description}", + ) # Send notification await self.context.send_messages( @@ -231,7 +243,11 @@ async def mark_criterion_completed(self, goal_id, criterion_id): ) ) - return True, f"Marked criterion '{criterion.description}' as completed.", dashboard + return ( + True, + f"Marked criterion '{criterion.description}' as completed.", + dashboard, + ) async def report_project_completion(self, completion_summary): # Mock implementation @@ -243,20 +259,20 @@ async def report_project_completion(self, completion_summary): conversation_id=str(self.context.id), project_id="test-project-id", ) - dashboard.status_message = completion_summary + dashboard.transfer_notes = completion_summary # Call mocked log_action - await self.log_action(LogEntryType.PROJECT_COMPLETED, "Project marked as completed") + await self.log_action(LogEntryType.PROJECT_COMPLETED, "KnowledgePackage marked as completed") # Send notification await self.context.send_messages( NewConversationMessage( - content="🎉 Project has been marked as completed.", + content="🎉 KnowledgePackage has been marked as completed.", message_type=MessageType.notice, ) ) - return True, "Project has been marked as completed", dashboard + return True, "KnowledgePackage has been marked as completed", dashboard async def get_project_info(self): # Mock implementation @@ -264,7 +280,7 @@ async def get_project_info(self): "has_project": True, "project_id": "test-project-id", "role": "team", - "title": "Test Project", + "title": "Test KnowledgePackage", "description": "A test project", "status": "in_progress", "progress": 50, @@ -335,8 +351,8 @@ async def test_update_project_info(self, team_handler, mock_context): assert success is True assert "Updated project progress to 50%" in message assert dashboard is not None - assert dashboard.progress_percentage == 50 - assert dashboard.status_message == "Making progress in the team" + # Note: completion_percentage removed from model + assert dashboard.transfer_notes == "Making progress in the team" assert dashboard.state == ProjectState.IN_PROGRESS # Verify that a notification was sent @@ -349,17 +365,19 @@ async def test_update_project_info(self, team_handler, mock_context): async def test_mark_criterion_completed(self, team_handler, mock_context): """Test marking a success criterion as completed.""" # Call the method - success, message, updated_dashboard = await team_handler.mark_criterion_completed( - "test-goal-id", "test-criterion-id" - ) + ( + success, + message, + updated_dashboard, + ) = await team_handler.mark_criterion_completed("test-goal-id", "test-criterion-id") # Assertions assert success is True assert "Marked criterion" in message assert updated_dashboard is not None - assert updated_dashboard.completed_criteria == 1 + assert updated_dashboard.achieved_criteria == 1 assert updated_dashboard.total_criteria == 1 - assert updated_dashboard.progress_percentage == 100 # 1/1 = 100% + # Note: completion_percentage removed from model # Verify that a notification was sent mock_context.send_messages.assert_called_once() @@ -372,16 +390,18 @@ async def test_report_project_completion(self, team_handler, mock_context): """Test reporting project completion.""" # Call the method success, message, dashboard = await team_handler.report_project_completion( - "Project has been successfully completed with all objectives achieved." + "KnowledgePackage has been successfully completed with all objectives achieved." ) # Assertions assert success is True - assert "Project has been marked as completed" in message + assert "KnowledgePackage has been marked as completed" in message assert dashboard is not None assert dashboard.state == ProjectState.COMPLETED - assert dashboard.progress_percentage == 100 - assert dashboard.status_message == "Project has been successfully completed with all objectives achieved." + # Note: completion_percentage removed from model + assert ( + dashboard.transfer_notes == "KnowledgePackage has been successfully completed with all objectives achieved." + ) # Verify that a notification was sent mock_context.send_messages.assert_called_once() @@ -399,6 +419,6 @@ async def test_get_project_info(self, team_handler, mock_context): assert project_info["has_project"] is True assert project_info["project_id"] == "test-project-id" assert project_info["role"] == "team" - assert project_info["title"] == "Test Project" + assert project_info["title"] == "Test KnowledgePackage" assert project_info["status"] == "in_progress" assert project_info["progress"] == 50 diff --git a/assistants/project-assistant/uv.lock b/assistants/project-assistant/uv.lock index 93bc85f87..535d2c37b 100644 --- a/assistants/project-assistant/uv.lock +++ b/assistants/project-assistant/uv.lock @@ -4,16 +4,16 @@ requires-python = ">=3.11" [[package]] name = "aiohappyeyeballs" -version = "2.4.6" +version = "2.6.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/08/07/508f9ebba367fc3370162e53a3cfd12f5652ad79f0e0bfdf9f9847c6f159/aiohappyeyeballs-2.4.6.tar.gz", hash = "sha256:9b05052f9042985d32ecbe4b59a77ae19c006a78f1344d7fdad69d28ded3d0b0", size = 21726, upload-time = "2025-02-07T17:53:12.277Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/4c/03fb05f56551828ec67ceb3665e5dc51638042d204983a03b0a1541475b6/aiohappyeyeballs-2.4.6-py3-none-any.whl", hash = "sha256:147ec992cf873d74f5062644332c539fcd42956dc69453fe5204195e560517e1", size = 14543, upload-time = "2025-02-07T17:53:10.304Z" }, + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, ] [[package]] name = "aiohttp" -version = "3.11.12" +version = "3.12.15" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -24,68 +24,72 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/37/4b/952d49c73084fb790cb5c6ead50848c8e96b4980ad806cf4d2ad341eaa03/aiohttp-3.11.12.tar.gz", hash = "sha256:7603ca26d75b1b86160ce1bbe2787a0b706e592af5b2504e12caa88a217767b0", size = 7673175, upload-time = "2025-02-06T00:28:47.88Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/38/35311e70196b6a63cfa033a7f741f800aa8a93f57442991cbe51da2394e7/aiohttp-3.11.12-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:87a2e00bf17da098d90d4145375f1d985a81605267e7f9377ff94e55c5d769eb", size = 708797, upload-time = "2025-02-06T00:26:08.81Z" }, - { url = "https://files.pythonhosted.org/packages/44/3e/46c656e68cbfc4f3fc7cb5d2ba4da6e91607fe83428208028156688f6201/aiohttp-3.11.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b34508f1cd928ce915ed09682d11307ba4b37d0708d1f28e5774c07a7674cac9", size = 468669, upload-time = "2025-02-06T00:26:10.475Z" }, - { url = "https://files.pythonhosted.org/packages/a0/d6/2088fb4fd1e3ac2bfb24bc172223babaa7cdbb2784d33c75ec09e66f62f8/aiohttp-3.11.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:936d8a4f0f7081327014742cd51d320296b56aa6d324461a13724ab05f4b2933", size = 455739, upload-time = "2025-02-06T00:26:11.974Z" }, - { url = "https://files.pythonhosted.org/packages/e7/dc/c443a6954a56f4a58b5efbfdf23cc6f3f0235e3424faf5a0c56264d5c7bb/aiohttp-3.11.12-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2de1378f72def7dfb5dbd73d86c19eda0ea7b0a6873910cc37d57e80f10d64e1", size = 1685858, upload-time = "2025-02-06T00:26:13.654Z" }, - { url = "https://files.pythonhosted.org/packages/25/67/2d5b3aaade1d5d01c3b109aa76e3aa9630531252cda10aa02fb99b0b11a1/aiohttp-3.11.12-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9d45dbb3aaec05cf01525ee1a7ac72de46a8c425cb75c003acd29f76b1ffe94", size = 1743829, upload-time = "2025-02-06T00:26:15.481Z" }, - { url = "https://files.pythonhosted.org/packages/90/9b/9728fe9a3e1b8521198455d027b0b4035522be18f504b24c5d38d59e7278/aiohttp-3.11.12-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:930ffa1925393381e1e0a9b82137fa7b34c92a019b521cf9f41263976666a0d6", size = 1785587, upload-time = "2025-02-06T00:26:17.171Z" }, - { url = "https://files.pythonhosted.org/packages/ce/cf/28fbb43d4ebc1b4458374a3c7b6db3b556a90e358e9bbcfe6d9339c1e2b6/aiohttp-3.11.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8340def6737118f5429a5df4e88f440746b791f8f1c4ce4ad8a595f42c980bd5", size = 1675319, upload-time = "2025-02-06T00:26:19.951Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d2/006c459c11218cabaa7bca401f965c9cc828efbdea7e1615d4644eaf23f7/aiohttp-3.11.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4016e383f91f2814e48ed61e6bda7d24c4d7f2402c75dd28f7e1027ae44ea204", size = 1619982, upload-time = "2025-02-06T00:26:21.705Z" }, - { url = "https://files.pythonhosted.org/packages/9d/83/ca425891ebd37bee5d837110f7fddc4d808a7c6c126a7d1b5c3ad72fc6ba/aiohttp-3.11.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3c0600bcc1adfaaac321422d615939ef300df81e165f6522ad096b73439c0f58", size = 1654176, upload-time = "2025-02-06T00:26:23.607Z" }, - { url = "https://files.pythonhosted.org/packages/25/df/047b1ce88514a1b4915d252513640184b63624e7914e41d846668b8edbda/aiohttp-3.11.12-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:0450ada317a65383b7cce9576096150fdb97396dcfe559109b403c7242faffef", size = 1660198, upload-time = "2025-02-06T00:26:26.686Z" }, - { url = "https://files.pythonhosted.org/packages/d3/cc/6ecb8e343f0902528620b9dbd567028a936d5489bebd7dbb0dd0914f4fdb/aiohttp-3.11.12-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:850ff6155371fd802a280f8d369d4e15d69434651b844bde566ce97ee2277420", size = 1650186, upload-time = "2025-02-06T00:26:28.479Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f8/453df6dd69256ca8c06c53fc8803c9056e2b0b16509b070f9a3b4bdefd6c/aiohttp-3.11.12-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8fd12d0f989c6099e7b0f30dc6e0d1e05499f3337461f0b2b0dadea6c64b89df", size = 1733063, upload-time = "2025-02-06T00:26:31.136Z" }, - { url = "https://files.pythonhosted.org/packages/55/f8/540160787ff3000391de0e5d0d1d33be4c7972f933c21991e2ea105b2d5e/aiohttp-3.11.12-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:76719dd521c20a58a6c256d058547b3a9595d1d885b830013366e27011ffe804", size = 1755306, upload-time = "2025-02-06T00:26:34.133Z" }, - { url = "https://files.pythonhosted.org/packages/30/7d/49f3bfdfefd741576157f8f91caa9ff61a6f3d620ca6339268327518221b/aiohttp-3.11.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97fe431f2ed646a3b56142fc81d238abcbaff08548d6912acb0b19a0cadc146b", size = 1692909, upload-time = "2025-02-06T00:26:37.281Z" }, - { url = "https://files.pythonhosted.org/packages/40/9c/8ce00afd6f6112ce9a2309dc490fea376ae824708b94b7b5ea9cba979d1d/aiohttp-3.11.12-cp311-cp311-win32.whl", hash = "sha256:e10c440d142fa8b32cfdb194caf60ceeceb3e49807072e0dc3a8887ea80e8c16", size = 416584, upload-time = "2025-02-06T00:26:39.946Z" }, - { url = "https://files.pythonhosted.org/packages/35/97/4d3c5f562f15830de472eb10a7a222655d750839943e0e6d915ef7e26114/aiohttp-3.11.12-cp311-cp311-win_amd64.whl", hash = "sha256:246067ba0cf5560cf42e775069c5d80a8989d14a7ded21af529a4e10e3e0f0e6", size = 442674, upload-time = "2025-02-06T00:26:42.193Z" }, - { url = "https://files.pythonhosted.org/packages/4d/d0/94346961acb476569fca9a644cc6f9a02f97ef75961a6b8d2b35279b8d1f/aiohttp-3.11.12-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e392804a38353900c3fd8b7cacbea5132888f7129f8e241915e90b85f00e3250", size = 704837, upload-time = "2025-02-06T00:26:44.812Z" }, - { url = "https://files.pythonhosted.org/packages/a9/af/05c503f1cc8f97621f199ef4b8db65fb88b8bc74a26ab2adb74789507ad3/aiohttp-3.11.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8fa1510b96c08aaad49303ab11f8803787c99222288f310a62f493faf883ede1", size = 464218, upload-time = "2025-02-06T00:26:46.533Z" }, - { url = "https://files.pythonhosted.org/packages/f2/48/b9949eb645b9bd699153a2ec48751b985e352ab3fed9d98c8115de305508/aiohttp-3.11.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dc065a4285307607df3f3686363e7f8bdd0d8ab35f12226362a847731516e42c", size = 456166, upload-time = "2025-02-06T00:26:48.142Z" }, - { url = "https://files.pythonhosted.org/packages/14/fb/980981807baecb6f54bdd38beb1bd271d9a3a786e19a978871584d026dcf/aiohttp-3.11.12-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddb31f8474695cd61fc9455c644fc1606c164b93bff2490390d90464b4655df", size = 1682528, upload-time = "2025-02-06T00:26:49.985Z" }, - { url = "https://files.pythonhosted.org/packages/90/cb/77b1445e0a716914e6197b0698b7a3640590da6c692437920c586764d05b/aiohttp-3.11.12-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9dec0000d2d8621d8015c293e24589d46fa218637d820894cb7356c77eca3259", size = 1737154, upload-time = "2025-02-06T00:26:51.913Z" }, - { url = "https://files.pythonhosted.org/packages/ff/24/d6fb1f4cede9ccbe98e4def6f3ed1e1efcb658871bbf29f4863ec646bf38/aiohttp-3.11.12-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3552fe98e90fdf5918c04769f338a87fa4f00f3b28830ea9b78b1bdc6140e0d", size = 1793435, upload-time = "2025-02-06T00:26:56.182Z" }, - { url = "https://files.pythonhosted.org/packages/17/e2/9f744cee0861af673dc271a3351f59ebd5415928e20080ab85be25641471/aiohttp-3.11.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dfe7f984f28a8ae94ff3a7953cd9678550dbd2a1f9bda5dd9c5ae627744c78e", size = 1692010, upload-time = "2025-02-06T00:26:58.504Z" }, - { url = "https://files.pythonhosted.org/packages/90/c4/4a1235c1df544223eb57ba553ce03bc706bdd065e53918767f7fa1ff99e0/aiohttp-3.11.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a481a574af914b6e84624412666cbfbe531a05667ca197804ecc19c97b8ab1b0", size = 1619481, upload-time = "2025-02-06T00:27:01.477Z" }, - { url = "https://files.pythonhosted.org/packages/60/70/cf12d402a94a33abda86dd136eb749b14c8eb9fec1e16adc310e25b20033/aiohttp-3.11.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1987770fb4887560363b0e1a9b75aa303e447433c41284d3af2840a2f226d6e0", size = 1641578, upload-time = "2025-02-06T00:27:06.151Z" }, - { url = "https://files.pythonhosted.org/packages/1b/25/7211973fda1f5e833fcfd98ccb7f9ce4fbfc0074e3e70c0157a751d00db8/aiohttp-3.11.12-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a4ac6a0f0f6402854adca4e3259a623f5c82ec3f0c049374133bcb243132baf9", size = 1684463, upload-time = "2025-02-06T00:27:08.336Z" }, - { url = "https://files.pythonhosted.org/packages/93/60/b5905b4d0693f6018b26afa9f2221fefc0dcbd3773fe2dff1a20fb5727f1/aiohttp-3.11.12-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c96a43822f1f9f69cc5c3706af33239489a6294be486a0447fb71380070d4d5f", size = 1646691, upload-time = "2025-02-06T00:27:11.232Z" }, - { url = "https://files.pythonhosted.org/packages/b4/fc/ba1b14d6fdcd38df0b7c04640794b3683e949ea10937c8a58c14d697e93f/aiohttp-3.11.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a5e69046f83c0d3cb8f0d5bd9b8838271b1bc898e01562a04398e160953e8eb9", size = 1702269, upload-time = "2025-02-06T00:27:13.639Z" }, - { url = "https://files.pythonhosted.org/packages/5e/39/18c13c6f658b2ba9cc1e0c6fb2d02f98fd653ad2addcdf938193d51a9c53/aiohttp-3.11.12-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:68d54234c8d76d8ef74744f9f9fc6324f1508129e23da8883771cdbb5818cbef", size = 1734782, upload-time = "2025-02-06T00:27:15.651Z" }, - { url = "https://files.pythonhosted.org/packages/9f/d2/ccc190023020e342419b265861877cd8ffb75bec37b7ddd8521dd2c6deb8/aiohttp-3.11.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9fd9dcf9c91affe71654ef77426f5cf8489305e1c66ed4816f5a21874b094b9", size = 1694740, upload-time = "2025-02-06T00:27:18.882Z" }, - { url = "https://files.pythonhosted.org/packages/3f/54/186805bcada64ea90ea909311ffedcd74369bfc6e880d39d2473314daa36/aiohttp-3.11.12-cp312-cp312-win32.whl", hash = "sha256:0ed49efcd0dc1611378beadbd97beb5d9ca8fe48579fc04a6ed0844072261b6a", size = 411530, upload-time = "2025-02-06T00:27:20.899Z" }, - { url = "https://files.pythonhosted.org/packages/3d/63/5eca549d34d141bcd9de50d4e59b913f3641559460c739d5e215693cb54a/aiohttp-3.11.12-cp312-cp312-win_amd64.whl", hash = "sha256:54775858c7f2f214476773ce785a19ee81d1294a6bedc5cc17225355aab74802", size = 437860, upload-time = "2025-02-06T00:27:23.674Z" }, - { url = "https://files.pythonhosted.org/packages/c3/9b/cea185d4b543ae08ee478373e16653722c19fcda10d2d0646f300ce10791/aiohttp-3.11.12-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:413ad794dccb19453e2b97c2375f2ca3cdf34dc50d18cc2693bd5aed7d16f4b9", size = 698148, upload-time = "2025-02-06T00:27:25.478Z" }, - { url = "https://files.pythonhosted.org/packages/91/5c/80d47fe7749fde584d1404a68ade29bcd7e58db8fa11fa38e8d90d77e447/aiohttp-3.11.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a93d28ed4b4b39e6f46fd240896c29b686b75e39cc6992692e3922ff6982b4c", size = 460831, upload-time = "2025-02-06T00:27:28.252Z" }, - { url = "https://files.pythonhosted.org/packages/8e/f9/de568f8a8ca6b061d157c50272620c53168d6e3eeddae78dbb0f7db981eb/aiohttp-3.11.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d589264dbba3b16e8951b6f145d1e6b883094075283dafcab4cdd564a9e353a0", size = 453122, upload-time = "2025-02-06T00:27:30.143Z" }, - { url = "https://files.pythonhosted.org/packages/8b/fd/b775970a047543bbc1d0f66725ba72acef788028fce215dc959fd15a8200/aiohttp-3.11.12-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5148ca8955affdfeb864aca158ecae11030e952b25b3ae15d4e2b5ba299bad2", size = 1665336, upload-time = "2025-02-06T00:27:31.982Z" }, - { url = "https://files.pythonhosted.org/packages/82/9b/aff01d4f9716245a1b2965f02044e4474fadd2bcfe63cf249ca788541886/aiohttp-3.11.12-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:525410e0790aab036492eeea913858989c4cb070ff373ec3bc322d700bdf47c1", size = 1718111, upload-time = "2025-02-06T00:27:33.983Z" }, - { url = "https://files.pythonhosted.org/packages/e0/a9/166fd2d8b2cc64f08104aa614fad30eee506b563154081bf88ce729bc665/aiohttp-3.11.12-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bd8695be2c80b665ae3f05cb584093a1e59c35ecb7d794d1edd96e8cc9201d7", size = 1775293, upload-time = "2025-02-06T00:27:36.105Z" }, - { url = "https://files.pythonhosted.org/packages/13/c5/0d3c89bd9e36288f10dc246f42518ce8e1c333f27636ac78df091c86bb4a/aiohttp-3.11.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0203433121484b32646a5f5ea93ae86f3d9559d7243f07e8c0eab5ff8e3f70e", size = 1677338, upload-time = "2025-02-06T00:27:38.238Z" }, - { url = "https://files.pythonhosted.org/packages/72/b2/017db2833ef537be284f64ead78725984db8a39276c1a9a07c5c7526e238/aiohttp-3.11.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40cd36749a1035c34ba8d8aaf221b91ca3d111532e5ccb5fa8c3703ab1b967ed", size = 1603365, upload-time = "2025-02-06T00:27:41.281Z" }, - { url = "https://files.pythonhosted.org/packages/fc/72/b66c96a106ec7e791e29988c222141dd1219d7793ffb01e72245399e08d2/aiohttp-3.11.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a7442662afebbf7b4c6d28cb7aab9e9ce3a5df055fc4116cc7228192ad6cb484", size = 1618464, upload-time = "2025-02-06T00:27:43.379Z" }, - { url = "https://files.pythonhosted.org/packages/3f/50/e68a40f267b46a603bab569d48d57f23508801614e05b3369898c5b2910a/aiohttp-3.11.12-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8a2fb742ef378284a50766e985804bd6adb5adb5aa781100b09befdbfa757b65", size = 1657827, upload-time = "2025-02-06T00:27:45.982Z" }, - { url = "https://files.pythonhosted.org/packages/c5/1d/aafbcdb1773d0ba7c20793ebeedfaba1f3f7462f6fc251f24983ed738aa7/aiohttp-3.11.12-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cee3b117a8d13ab98b38d5b6bdcd040cfb4181068d05ce0c474ec9db5f3c5bb", size = 1616700, upload-time = "2025-02-06T00:27:48.17Z" }, - { url = "https://files.pythonhosted.org/packages/b0/5e/6cd9724a2932f36e2a6b742436a36d64784322cfb3406ca773f903bb9a70/aiohttp-3.11.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f6a19bcab7fbd8f8649d6595624856635159a6527861b9cdc3447af288a00c00", size = 1685643, upload-time = "2025-02-06T00:27:51.183Z" }, - { url = "https://files.pythonhosted.org/packages/8b/38/ea6c91d5c767fd45a18151675a07c710ca018b30aa876a9f35b32fa59761/aiohttp-3.11.12-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e4cecdb52aaa9994fbed6b81d4568427b6002f0a91c322697a4bfcc2b2363f5a", size = 1715487, upload-time = "2025-02-06T00:27:53.431Z" }, - { url = "https://files.pythonhosted.org/packages/8e/24/e9edbcb7d1d93c02e055490348df6f955d675e85a028c33babdcaeda0853/aiohttp-3.11.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:30f546358dfa0953db92ba620101fefc81574f87b2346556b90b5f3ef16e55ce", size = 1672948, upload-time = "2025-02-06T00:27:56.137Z" }, - { url = "https://files.pythonhosted.org/packages/25/be/0b1fb737268e003198f25c3a68c2135e76e4754bf399a879b27bd508a003/aiohttp-3.11.12-cp313-cp313-win32.whl", hash = "sha256:ce1bb21fc7d753b5f8a5d5a4bae99566386b15e716ebdb410154c16c91494d7f", size = 410396, upload-time = "2025-02-06T00:27:58.292Z" }, - { url = "https://files.pythonhosted.org/packages/68/fd/677def96a75057b0a26446b62f8fbb084435b20a7d270c99539c26573bfd/aiohttp-3.11.12-cp313-cp313-win_amd64.whl", hash = "sha256:f7914ab70d2ee8ab91c13e5402122edbc77821c66d2758abb53aabe87f013287", size = 436234, upload-time = "2025-02-06T00:28:01.693Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2", size = 7823716, upload-time = "2025-07-29T05:52:32.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/19/9e86722ec8e835959bd97ce8c1efa78cf361fa4531fca372551abcc9cdd6/aiohttp-3.12.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d3ce17ce0220383a0f9ea07175eeaa6aa13ae5a41f30bc61d84df17f0e9b1117", size = 711246, upload-time = "2025-07-29T05:50:15.937Z" }, + { url = "https://files.pythonhosted.org/packages/71/f9/0a31fcb1a7d4629ac9d8f01f1cb9242e2f9943f47f5d03215af91c3c1a26/aiohttp-3.12.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:010cc9bbd06db80fe234d9003f67e97a10fe003bfbedb40da7d71c1008eda0fe", size = 483515, upload-time = "2025-07-29T05:50:17.442Z" }, + { url = "https://files.pythonhosted.org/packages/62/6c/94846f576f1d11df0c2e41d3001000527c0fdf63fce7e69b3927a731325d/aiohttp-3.12.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3f9d7c55b41ed687b9d7165b17672340187f87a773c98236c987f08c858145a9", size = 471776, upload-time = "2025-07-29T05:50:19.568Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6c/f766d0aaafcee0447fad0328da780d344489c042e25cd58fde566bf40aed/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc4fbc61bb3548d3b482f9ac7ddd0f18c67e4225aaa4e8552b9f1ac7e6bda9e5", size = 1741977, upload-time = "2025-07-29T05:50:21.665Z" }, + { url = "https://files.pythonhosted.org/packages/17/e5/fb779a05ba6ff44d7bc1e9d24c644e876bfff5abe5454f7b854cace1b9cc/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7fbc8a7c410bb3ad5d595bb7118147dfbb6449d862cc1125cf8867cb337e8728", size = 1690645, upload-time = "2025-07-29T05:50:23.333Z" }, + { url = "https://files.pythonhosted.org/packages/37/4e/a22e799c2035f5d6a4ad2cf8e7c1d1bd0923192871dd6e367dafb158b14c/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74dad41b3458dbb0511e760fb355bb0b6689e0630de8a22b1b62a98777136e16", size = 1789437, upload-time = "2025-07-29T05:50:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/28/e5/55a33b991f6433569babb56018b2fb8fb9146424f8b3a0c8ecca80556762/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6f0af863cf17e6222b1735a756d664159e58855da99cfe965134a3ff63b0b0", size = 1828482, upload-time = "2025-07-29T05:50:26.693Z" }, + { url = "https://files.pythonhosted.org/packages/c6/82/1ddf0ea4f2f3afe79dffed5e8a246737cff6cbe781887a6a170299e33204/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5b7fe4972d48a4da367043b8e023fb70a04d1490aa7d68800e465d1b97e493b", size = 1730944, upload-time = "2025-07-29T05:50:28.382Z" }, + { url = "https://files.pythonhosted.org/packages/1b/96/784c785674117b4cb3877522a177ba1b5e4db9ce0fd519430b5de76eec90/aiohttp-3.12.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6443cca89553b7a5485331bc9bedb2342b08d073fa10b8c7d1c60579c4a7b9bd", size = 1668020, upload-time = "2025-07-29T05:50:30.032Z" }, + { url = "https://files.pythonhosted.org/packages/12/8a/8b75f203ea7e5c21c0920d84dd24a5c0e971fe1e9b9ebbf29ae7e8e39790/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c5f40ec615e5264f44b4282ee27628cea221fcad52f27405b80abb346d9f3f8", size = 1716292, upload-time = "2025-07-29T05:50:31.983Z" }, + { url = "https://files.pythonhosted.org/packages/47/0b/a1451543475bb6b86a5cfc27861e52b14085ae232896a2654ff1231c0992/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2abbb216a1d3a2fe86dbd2edce20cdc5e9ad0be6378455b05ec7f77361b3ab50", size = 1711451, upload-time = "2025-07-29T05:50:33.989Z" }, + { url = "https://files.pythonhosted.org/packages/55/fd/793a23a197cc2f0d29188805cfc93aa613407f07e5f9da5cd1366afd9d7c/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:db71ce547012a5420a39c1b744d485cfb823564d01d5d20805977f5ea1345676", size = 1691634, upload-time = "2025-07-29T05:50:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/ca/bf/23a335a6670b5f5dfc6d268328e55a22651b440fca341a64fccf1eada0c6/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ced339d7c9b5030abad5854aa5413a77565e5b6e6248ff927d3e174baf3badf7", size = 1785238, upload-time = "2025-07-29T05:50:37.597Z" }, + { url = "https://files.pythonhosted.org/packages/57/4f/ed60a591839a9d85d40694aba5cef86dde9ee51ce6cca0bb30d6eb1581e7/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:7c7dd29c7b5bda137464dc9bfc738d7ceea46ff70309859ffde8c022e9b08ba7", size = 1805701, upload-time = "2025-07-29T05:50:39.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/e0/444747a9455c5de188c0f4a0173ee701e2e325d4b2550e9af84abb20cdba/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:421da6fd326460517873274875c6c5a18ff225b40da2616083c5a34a7570b685", size = 1718758, upload-time = "2025-07-29T05:50:41.292Z" }, + { url = "https://files.pythonhosted.org/packages/36/ab/1006278d1ffd13a698e5dd4bfa01e5878f6bddefc296c8b62649753ff249/aiohttp-3.12.15-cp311-cp311-win32.whl", hash = "sha256:4420cf9d179ec8dfe4be10e7d0fe47d6d606485512ea2265b0d8c5113372771b", size = 428868, upload-time = "2025-07-29T05:50:43.063Z" }, + { url = "https://files.pythonhosted.org/packages/10/97/ad2b18700708452400278039272032170246a1bf8ec5d832772372c71f1a/aiohttp-3.12.15-cp311-cp311-win_amd64.whl", hash = "sha256:edd533a07da85baa4b423ee8839e3e91681c7bfa19b04260a469ee94b778bf6d", size = 453273, upload-time = "2025-07-29T05:50:44.613Z" }, + { url = "https://files.pythonhosted.org/packages/63/97/77cb2450d9b35f517d6cf506256bf4f5bda3f93a66b4ad64ba7fc917899c/aiohttp-3.12.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:802d3868f5776e28f7bf69d349c26fc0efadb81676d0afa88ed00d98a26340b7", size = 702333, upload-time = "2025-07-29T05:50:46.507Z" }, + { url = "https://files.pythonhosted.org/packages/83/6d/0544e6b08b748682c30b9f65640d006e51f90763b41d7c546693bc22900d/aiohttp-3.12.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2800614cd560287be05e33a679638e586a2d7401f4ddf99e304d98878c29444", size = 476948, upload-time = "2025-07-29T05:50:48.067Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1d/c8c40e611e5094330284b1aea8a4b02ca0858f8458614fa35754cab42b9c/aiohttp-3.12.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8466151554b593909d30a0a125d638b4e5f3836e5aecde85b66b80ded1cb5b0d", size = 469787, upload-time = "2025-07-29T05:50:49.669Z" }, + { url = "https://files.pythonhosted.org/packages/38/7d/b76438e70319796bfff717f325d97ce2e9310f752a267bfdf5192ac6082b/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e5a495cb1be69dae4b08f35a6c4579c539e9b5706f606632102c0f855bcba7c", size = 1716590, upload-time = "2025-07-29T05:50:51.368Z" }, + { url = "https://files.pythonhosted.org/packages/79/b1/60370d70cdf8b269ee1444b390cbd72ce514f0d1cd1a715821c784d272c9/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6404dfc8cdde35c69aaa489bb3542fb86ef215fc70277c892be8af540e5e21c0", size = 1699241, upload-time = "2025-07-29T05:50:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2b/4968a7b8792437ebc12186db31523f541943e99bda8f30335c482bea6879/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ead1c00f8521a5c9070fcb88f02967b1d8a0544e6d85c253f6968b785e1a2ab", size = 1754335, upload-time = "2025-07-29T05:50:55.394Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c1/49524ed553f9a0bec1a11fac09e790f49ff669bcd14164f9fab608831c4d/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6990ef617f14450bc6b34941dba4f12d5613cbf4e33805932f853fbd1cf18bfb", size = 1800491, upload-time = "2025-07-29T05:50:57.202Z" }, + { url = "https://files.pythonhosted.org/packages/de/5e/3bf5acea47a96a28c121b167f5ef659cf71208b19e52a88cdfa5c37f1fcc/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545", size = 1719929, upload-time = "2025-07-29T05:50:59.192Z" }, + { url = "https://files.pythonhosted.org/packages/39/94/8ae30b806835bcd1cba799ba35347dee6961a11bd507db634516210e91d8/aiohttp-3.12.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c5092ce14361a73086b90c6efb3948ffa5be2f5b6fbcf52e8d8c8b8848bb97c", size = 1635733, upload-time = "2025-07-29T05:51:01.394Z" }, + { url = "https://files.pythonhosted.org/packages/7a/46/06cdef71dd03acd9da7f51ab3a9107318aee12ad38d273f654e4f981583a/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aaa2234bb60c4dbf82893e934d8ee8dea30446f0647e024074237a56a08c01bd", size = 1696790, upload-time = "2025-07-29T05:51:03.657Z" }, + { url = "https://files.pythonhosted.org/packages/02/90/6b4cfaaf92ed98d0ec4d173e78b99b4b1a7551250be8937d9d67ecb356b4/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6d86a2fbdd14192e2f234a92d3b494dd4457e683ba07e5905a0b3ee25389ac9f", size = 1718245, upload-time = "2025-07-29T05:51:05.911Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e6/2593751670fa06f080a846f37f112cbe6f873ba510d070136a6ed46117c6/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a041e7e2612041a6ddf1c6a33b883be6a421247c7afd47e885969ee4cc58bd8d", size = 1658899, upload-time = "2025-07-29T05:51:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/8f/28/c15bacbdb8b8eb5bf39b10680d129ea7410b859e379b03190f02fa104ffd/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5015082477abeafad7203757ae44299a610e89ee82a1503e3d4184e6bafdd519", size = 1738459, upload-time = "2025-07-29T05:51:09.56Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/c269cbc4faa01fb10f143b1670633a8ddd5b2e1ffd0548f7aa49cb5c70e2/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:56822ff5ddfd1b745534e658faba944012346184fbfe732e0d6134b744516eea", size = 1766434, upload-time = "2025-07-29T05:51:11.423Z" }, + { url = "https://files.pythonhosted.org/packages/52/b0/4ff3abd81aa7d929b27d2e1403722a65fc87b763e3a97b3a2a494bfc63bc/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b2acbbfff69019d9014508c4ba0401822e8bae5a5fdc3b6814285b71231b60f3", size = 1726045, upload-time = "2025-07-29T05:51:13.689Z" }, + { url = "https://files.pythonhosted.org/packages/71/16/949225a6a2dd6efcbd855fbd90cf476052e648fb011aa538e3b15b89a57a/aiohttp-3.12.15-cp312-cp312-win32.whl", hash = "sha256:d849b0901b50f2185874b9a232f38e26b9b3d4810095a7572eacea939132d4e1", size = 423591, upload-time = "2025-07-29T05:51:15.452Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d8/fa65d2a349fe938b76d309db1a56a75c4fb8cc7b17a398b698488a939903/aiohttp-3.12.15-cp312-cp312-win_amd64.whl", hash = "sha256:b390ef5f62bb508a9d67cb3bba9b8356e23b3996da7062f1a57ce1a79d2b3d34", size = 450266, upload-time = "2025-07-29T05:51:17.239Z" }, + { url = "https://files.pythonhosted.org/packages/f2/33/918091abcf102e39d15aba2476ad9e7bd35ddb190dcdd43a854000d3da0d/aiohttp-3.12.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9f922ffd05034d439dde1c77a20461cf4a1b0831e6caa26151fe7aa8aaebc315", size = 696741, upload-time = "2025-07-29T05:51:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/7495a81e39a998e400f3ecdd44a62107254803d1681d9189be5c2e4530cd/aiohttp-3.12.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ee8a8ac39ce45f3e55663891d4b1d15598c157b4d494a4613e704c8b43112cd", size = 474407, upload-time = "2025-07-29T05:51:21.165Z" }, + { url = "https://files.pythonhosted.org/packages/49/fc/a9576ab4be2dcbd0f73ee8675d16c707cfc12d5ee80ccf4015ba543480c9/aiohttp-3.12.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eae49032c29d356b94eee45a3f39fdf4b0814b397638c2f718e96cfadf4c4e4", size = 466703, upload-time = "2025-07-29T05:51:22.948Z" }, + { url = "https://files.pythonhosted.org/packages/09/2f/d4bcc8448cf536b2b54eed48f19682031ad182faa3a3fee54ebe5b156387/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97752ff12cc12f46a9b20327104448042fce5c33a624f88c18f66f9368091c7", size = 1705532, upload-time = "2025-07-29T05:51:25.211Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f3/59406396083f8b489261e3c011aa8aee9df360a96ac8fa5c2e7e1b8f0466/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:894261472691d6fe76ebb7fcf2e5870a2ac284c7406ddc95823c8598a1390f0d", size = 1686794, upload-time = "2025-07-29T05:51:27.145Z" }, + { url = "https://files.pythonhosted.org/packages/dc/71/164d194993a8d114ee5656c3b7ae9c12ceee7040d076bf7b32fb98a8c5c6/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5fa5d9eb82ce98959fc1031c28198b431b4d9396894f385cb63f1e2f3f20ca6b", size = 1738865, upload-time = "2025-07-29T05:51:29.366Z" }, + { url = "https://files.pythonhosted.org/packages/1c/00/d198461b699188a93ead39cb458554d9f0f69879b95078dce416d3209b54/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0fa751efb11a541f57db59c1dd821bec09031e01452b2b6217319b3a1f34f3d", size = 1788238, upload-time = "2025-07-29T05:51:31.285Z" }, + { url = "https://files.pythonhosted.org/packages/85/b8/9e7175e1fa0ac8e56baa83bf3c214823ce250d0028955dfb23f43d5e61fd/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5346b93e62ab51ee2a9d68e8f73c7cf96ffb73568a23e683f931e52450e4148d", size = 1710566, upload-time = "2025-07-29T05:51:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/59/e4/16a8eac9df39b48ae102ec030fa9f726d3570732e46ba0c592aeeb507b93/aiohttp-3.12.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:049ec0360f939cd164ecbfd2873eaa432613d5e77d6b04535e3d1fbae5a9e645", size = 1624270, upload-time = "2025-07-29T05:51:35.195Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/cd84dee7b6ace0740908fd0af170f9fab50c2a41ccbc3806aabcb1050141/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b52dcf013b57464b6d1e51b627adfd69a8053e84b7103a7cd49c030f9ca44461", size = 1677294, upload-time = "2025-07-29T05:51:37.215Z" }, + { url = "https://files.pythonhosted.org/packages/ce/42/d0f1f85e50d401eccd12bf85c46ba84f947a84839c8a1c2c5f6e8ab1eb50/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b2af240143dd2765e0fb661fd0361a1b469cab235039ea57663cda087250ea9", size = 1708958, upload-time = "2025-07-29T05:51:39.328Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6b/f6fa6c5790fb602538483aa5a1b86fcbad66244997e5230d88f9412ef24c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac77f709a2cde2cc71257ab2d8c74dd157c67a0558a0d2799d5d571b4c63d44d", size = 1651553, upload-time = "2025-07-29T05:51:41.356Z" }, + { url = "https://files.pythonhosted.org/packages/04/36/a6d36ad545fa12e61d11d1932eef273928b0495e6a576eb2af04297fdd3c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:47f6b962246f0a774fbd3b6b7be25d59b06fdb2f164cf2513097998fc6a29693", size = 1727688, upload-time = "2025-07-29T05:51:43.452Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c8/f195e5e06608a97a4e52c5d41c7927301bf757a8e8bb5bbf8cef6c314961/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:760fb7db442f284996e39cf9915a94492e1896baac44f06ae551974907922b64", size = 1761157, upload-time = "2025-07-29T05:51:45.643Z" }, + { url = "https://files.pythonhosted.org/packages/05/6a/ea199e61b67f25ba688d3ce93f63b49b0a4e3b3d380f03971b4646412fc6/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad702e57dc385cae679c39d318def49aef754455f237499d5b99bea4ef582e51", size = 1710050, upload-time = "2025-07-29T05:51:48.203Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2e/ffeb7f6256b33635c29dbed29a22a723ff2dd7401fff42ea60cf2060abfb/aiohttp-3.12.15-cp313-cp313-win32.whl", hash = "sha256:f813c3e9032331024de2eb2e32a88d86afb69291fbc37a3a3ae81cc9917fb3d0", size = 422647, upload-time = "2025-07-29T05:51:50.718Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8e/78ee35774201f38d5e1ba079c9958f7629b1fd079459aea9467441dbfbf5/aiohttp-3.12.15-cp313-cp313-win_amd64.whl", hash = "sha256:1a649001580bdb37c6fdb1bebbd7e3bc688e8ec2b5c6f52edbb664662b17dc84", size = 449067, upload-time = "2025-07-29T05:51:52.549Z" }, ] [[package]] name = "aiosignal" -version = "1.3.2" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/b5/6d55e80f6d8a08ce22b982eafa278d823b541c925f11ee774b0b9c43473d/aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54", size = 19424, upload-time = "2024-12-13T17:10:40.86Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/6a/bc7e17a3e87a2985d3e8f4da4cd0f481060eb78fb08596c42be62c90a4d9/aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", size = 7597, upload-time = "2024-12-13T17:10:38.469Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] [[package]] @@ -99,7 +103,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.49.0" +version = "0.64.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -110,9 +114,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/86/e3/a88c8494ce4d1a88252b9e053607e885f9b14d0a32273d47b727cbee4228/anthropic-0.49.0.tar.gz", hash = "sha256:c09e885b0f674b9119b4f296d8508907f6cff0009bc20d5cf6b35936c40b4398", size = 210016, upload-time = "2025-02-28T19:35:47.01Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/4f/f2b880cba1a76f3acc7d5eb2ae217632eac1b8cef5ed3027493545c59eba/anthropic-0.64.0.tar.gz", hash = "sha256:3d496c91a63dff64f451b3e8e4b238a9640bf87b0c11d0b74ddc372ba5a3fe58", size = 427893, upload-time = "2025-08-13T17:09:49.915Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/74/5d90ad14d55fbe3f9c474fdcb6e34b4bed99e3be8efac98734a5ddce88c1/anthropic-0.49.0-py3-none-any.whl", hash = "sha256:bbc17ad4e7094988d2fa86b87753ded8dce12498f4b85fe5810f208f454a8375", size = 243368, upload-time = "2025-02-28T19:35:44.963Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b2/2d268bcd5d6441df9dc0ebebc67107657edb8b0150d3fda1a5b81d1bec45/anthropic-0.64.0-py3-none-any.whl", hash = "sha256:6f5f7d913a6a95eb7f8e1bda4e75f76670e8acd8d4cd965e02e2a256b0429dd1", size = 297244, upload-time = "2025-08-13T17:09:47.908Z" }, ] [[package]] @@ -143,16 +147,16 @@ dev = [{ name = "pyright", specifier = ">=1.1.389" }] [[package]] name = "anyio" -version = "4.8.0" +version = "4.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "sniffio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/73/199a98fc2dae33535d6b8e8e6ec01f8c1d76c9adb096c6b7d64823038cde/anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a", size = 181126, upload-time = "2025-01-05T13:13:11.095Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041, upload-time = "2025-01-05T13:13:07.985Z" }, + { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, ] [[package]] @@ -175,11 +179,9 @@ source = { editable = "." } dependencies = [ { name = "assistant-extensions", extra = ["attachments"] }, { name = "content-safety" }, - { name = "deepmerge" }, { name = "openai" }, { name = "openai-client" }, { name = "semantic-workbench-assistant" }, - { name = "tiktoken" }, ] [package.dev-dependencies] @@ -194,11 +196,9 @@ dev = [ requires-dist = [ { name = "assistant-extensions", extras = ["attachments"], editable = "../../libraries/python/assistant-extensions" }, { name = "content-safety", editable = "../../libraries/python/content-safety" }, - { name = "deepmerge", specifier = ">=2.0" }, { name = "openai", specifier = ">=1.61.0" }, { name = "openai-client", editable = "../../libraries/python/openai-client" }, { name = "semantic-workbench-assistant", editable = "../../libraries/python/semantic-workbench-assistant" }, - { name = "tiktoken", specifier = ">=0.8.0" }, ] [package.metadata.requires-dev] @@ -281,11 +281,11 @@ dev = [ [[package]] name = "attrs" -version = "25.1.0" +version = "25.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/49/7c/fdf464bcc51d23881d110abd74b512a42b3d5d376a55a831b44c603ae17f/attrs-25.1.0.tar.gz", hash = "sha256:1c97078a80c814273a76b2a298a932eb681c87415c11dee0a6921de7f1b02c3e", size = 810562, upload-time = "2025-01-25T11:30:12.508Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/30/d4986a882011f9df997a55e6becd864812ccfcd821d64aac8570ee39f719/attrs-25.1.0-py3-none-any.whl", hash = "sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a", size = 63152, upload-time = "2025-01-25T11:30:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, ] [[package]] @@ -303,16 +303,16 @@ wheels = [ [[package]] name = "azure-core" -version = "1.32.0" +version = "1.35.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, { name = "six" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cc/ee/668328306a9e963a5ad9f152cd98c7adad86c822729fd1d2a01613ad1e67/azure_core-1.32.0.tar.gz", hash = "sha256:22b3c35d6b2dae14990f6c1be2912bf23ffe50b220e708a28ab1bb92b1c730e5", size = 279128, upload-time = "2024-10-31T17:45:17.528Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/89/f53968635b1b2e53e4aad2dd641488929fef4ca9dfb0b97927fa7697ddf3/azure_core-1.35.0.tar.gz", hash = "sha256:c0be528489485e9ede59b6971eb63c1eaacf83ef53001bfe3904e475e972be5c", size = 339689, upload-time = "2025-07-03T00:55:23.496Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/83/325bf5e02504dbd8b4faa98197a44cdf8a325ef259b48326a2b6f17f8383/azure_core-1.32.0-py3-none-any.whl", hash = "sha256:eac191a0efb23bfa83fddf321b27b122b4ec847befa3091fa736a5c32c50d7b4", size = 198855, upload-time = "2024-10-31T17:45:19.415Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/bf94897361fdd650850f0f2e405b2293e2f12808239046232bdedf554301/azure_core-1.35.0-py3-none-any.whl", hash = "sha256:8db78c72868a58f3de8991eb4d22c4d368fae226dac1002998d6c50437e7dad1", size = 210708, upload-time = "2025-07-03T00:55:25.238Z" }, ] [package.optional-dependencies] @@ -322,7 +322,7 @@ aio = [ [[package]] name = "azure-identity" -version = "1.20.0" +version = "1.24.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "azure-core" }, @@ -331,9 +331,9 @@ dependencies = [ { name = "msal-extensions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ee/89/7d170fab0b85d9650cdb7abda087e849644beb52bd28f6804620dd0cecd9/azure_identity-1.20.0.tar.gz", hash = "sha256:40597210d56c83e15031b0fe2ea3b26420189e1e7f3e20bdbb292315da1ba014", size = 264447, upload-time = "2025-02-12T00:40:41.225Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/44/f3ee20bacb220b6b4a2b0a6cf7e742eecb383a5ccf604dd79ec27c286b7e/azure_identity-1.24.0.tar.gz", hash = "sha256:6c3a40b2a70af831e920b89e6421e8dcd4af78a0cb38b9642d86c67643d4930c", size = 271630, upload-time = "2025-08-07T22:27:36.258Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/aa/819513c1dbef990af690bb5eefb5e337f8698d75dfdb7302528f50ce1994/azure_identity-1.20.0-py3-none-any.whl", hash = "sha256:5f23fc4889a66330e840bd78830287e14f3761820fe3c5f77ac875edcb9ec998", size = 188243, upload-time = "2025-02-12T00:40:44.99Z" }, + { url = "https://files.pythonhosted.org/packages/a9/74/17428cb429e8d52f6d0d69ed685f4760a545cb0156594963a9337b53b6c9/azure_identity-1.24.0-py3-none-any.whl", hash = "sha256:9e04997cde0ab02ed66422c74748548e620b7b29361c72ce622acab0267ff7c4", size = 187890, upload-time = "2025-08-07T22:27:38.033Z" }, ] [[package]] @@ -356,11 +356,11 @@ wheels = [ [[package]] name = "certifi" -version = "2025.1.31" +version = "2025.8.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577, upload-time = "2025-01-31T02:16:47.166Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393, upload-time = "2025-01-31T02:16:45.015Z" }, + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, ] [[package]] @@ -410,50 +410,55 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188, upload-time = "2024-12-24T18:12:35.43Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/72/80/41ef5d5a7935d2d3a773e3eaebf0a9350542f2cab4eac59a7a4741fbbbbe/charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125", size = 194995, upload-time = "2024-12-24T18:10:12.838Z" }, - { url = "https://files.pythonhosted.org/packages/7a/28/0b9fefa7b8b080ec492110af6d88aa3dea91c464b17d53474b6e9ba5d2c5/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1", size = 139471, upload-time = "2024-12-24T18:10:14.101Z" }, - { url = "https://files.pythonhosted.org/packages/71/64/d24ab1a997efb06402e3fc07317e94da358e2585165930d9d59ad45fcae2/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3", size = 149831, upload-time = "2024-12-24T18:10:15.512Z" }, - { url = "https://files.pythonhosted.org/packages/37/ed/be39e5258e198655240db5e19e0b11379163ad7070962d6b0c87ed2c4d39/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd", size = 142335, upload-time = "2024-12-24T18:10:18.369Z" }, - { url = "https://files.pythonhosted.org/packages/88/83/489e9504711fa05d8dde1574996408026bdbdbd938f23be67deebb5eca92/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00", size = 143862, upload-time = "2024-12-24T18:10:19.743Z" }, - { url = "https://files.pythonhosted.org/packages/c6/c7/32da20821cf387b759ad24627a9aca289d2822de929b8a41b6241767b461/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12", size = 145673, upload-time = "2024-12-24T18:10:21.139Z" }, - { url = "https://files.pythonhosted.org/packages/68/85/f4288e96039abdd5aeb5c546fa20a37b50da71b5cf01e75e87f16cd43304/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77", size = 140211, upload-time = "2024-12-24T18:10:22.382Z" }, - { url = "https://files.pythonhosted.org/packages/28/a3/a42e70d03cbdabc18997baf4f0227c73591a08041c149e710045c281f97b/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146", size = 148039, upload-time = "2024-12-24T18:10:24.802Z" }, - { url = "https://files.pythonhosted.org/packages/85/e4/65699e8ab3014ecbe6f5c71d1a55d810fb716bbfd74f6283d5c2aa87febf/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd", size = 151939, upload-time = "2024-12-24T18:10:26.124Z" }, - { url = "https://files.pythonhosted.org/packages/b1/82/8e9fe624cc5374193de6860aba3ea8070f584c8565ee77c168ec13274bd2/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6", size = 149075, upload-time = "2024-12-24T18:10:30.027Z" }, - { url = "https://files.pythonhosted.org/packages/3d/7b/82865ba54c765560c8433f65e8acb9217cb839a9e32b42af4aa8e945870f/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8", size = 144340, upload-time = "2024-12-24T18:10:32.679Z" }, - { url = "https://files.pythonhosted.org/packages/b5/b6/9674a4b7d4d99a0d2df9b215da766ee682718f88055751e1e5e753c82db0/charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b", size = 95205, upload-time = "2024-12-24T18:10:34.724Z" }, - { url = "https://files.pythonhosted.org/packages/1e/ab/45b180e175de4402dcf7547e4fb617283bae54ce35c27930a6f35b6bef15/charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76", size = 102441, upload-time = "2024-12-24T18:10:37.574Z" }, - { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105, upload-time = "2024-12-24T18:10:38.83Z" }, - { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404, upload-time = "2024-12-24T18:10:44.272Z" }, - { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423, upload-time = "2024-12-24T18:10:45.492Z" }, - { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184, upload-time = "2024-12-24T18:10:47.898Z" }, - { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268, upload-time = "2024-12-24T18:10:50.589Z" }, - { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601, upload-time = "2024-12-24T18:10:52.541Z" }, - { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098, upload-time = "2024-12-24T18:10:53.789Z" }, - { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520, upload-time = "2024-12-24T18:10:55.048Z" }, - { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852, upload-time = "2024-12-24T18:10:57.647Z" }, - { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488, upload-time = "2024-12-24T18:10:59.43Z" }, - { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192, upload-time = "2024-12-24T18:11:00.676Z" }, - { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550, upload-time = "2024-12-24T18:11:01.952Z" }, - { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785, upload-time = "2024-12-24T18:11:03.142Z" }, - { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698, upload-time = "2024-12-24T18:11:05.834Z" }, - { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162, upload-time = "2024-12-24T18:11:07.064Z" }, - { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263, upload-time = "2024-12-24T18:11:08.374Z" }, - { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966, upload-time = "2024-12-24T18:11:09.831Z" }, - { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992, upload-time = "2024-12-24T18:11:12.03Z" }, - { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162, upload-time = "2024-12-24T18:11:13.372Z" }, - { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972, upload-time = "2024-12-24T18:11:14.628Z" }, - { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095, upload-time = "2024-12-24T18:11:17.672Z" }, - { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668, upload-time = "2024-12-24T18:11:18.989Z" }, - { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073, upload-time = "2024-12-24T18:11:21.507Z" }, - { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732, upload-time = "2024-12-24T18:11:22.774Z" }, - { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391, upload-time = "2024-12-24T18:11:24.139Z" }, - { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702, upload-time = "2024-12-24T18:11:26.535Z" }, - { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767, upload-time = "2024-12-24T18:12:32.852Z" }, +version = "3.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483, upload-time = "2025-08-09T07:55:53.12Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520, upload-time = "2025-08-09T07:55:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876, upload-time = "2025-08-09T07:55:56.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083, upload-time = "2025-08-09T07:55:57.582Z" }, + { url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295, upload-time = "2025-08-09T07:55:59.147Z" }, + { url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379, upload-time = "2025-08-09T07:56:00.364Z" }, + { url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018, upload-time = "2025-08-09T07:56:01.678Z" }, + { url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430, upload-time = "2025-08-09T07:56:02.87Z" }, + { url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600, upload-time = "2025-08-09T07:56:04.089Z" }, + { url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616, upload-time = "2025-08-09T07:56:05.658Z" }, + { url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108, upload-time = "2025-08-09T07:56:07.176Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, + { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, + { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, + { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, + { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, + { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, + { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, + { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, + { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, + { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, + { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, + { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, + { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, + { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, + { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, + { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, + { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, ] [[package]] @@ -484,14 +489,14 @@ dev = [ [[package]] name = "click" -version = "8.1.8" +version = "8.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } +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/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, + { 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]] @@ -529,37 +534,43 @@ dev = [{ name = "pyright", specifier = ">=1.1.389" }] [[package]] name = "cryptography" -version = "44.0.1" +version = "45.0.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/67/545c79fe50f7af51dbad56d16b23fe33f63ee6a5d956b3cb68ea110cbe64/cryptography-44.0.1.tar.gz", hash = "sha256:f51f5705ab27898afda1aaa430f34ad90dc117421057782022edf0600bec5f14", size = 710819, upload-time = "2025-02-11T15:50:58.39Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/72/27/5e3524053b4c8889da65cf7814a9d0d8514a05194a25e1e34f46852ee6eb/cryptography-44.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf688f615c29bfe9dfc44312ca470989279f0e94bb9f631f85e3459af8efc009", size = 6642022, upload-time = "2025-02-11T15:49:32.752Z" }, - { url = "https://files.pythonhosted.org/packages/34/b9/4d1fa8d73ae6ec350012f89c3abfbff19fc95fe5420cf972e12a8d182986/cryptography-44.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd7c7e2d71d908dc0f8d2027e1604102140d84b155e658c20e8ad1304317691f", size = 3943865, upload-time = "2025-02-11T15:49:36.659Z" }, - { url = "https://files.pythonhosted.org/packages/6e/57/371a9f3f3a4500807b5fcd29fec77f418ba27ffc629d88597d0d1049696e/cryptography-44.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:887143b9ff6bad2b7570da75a7fe8bbf5f65276365ac259a5d2d5147a73775f2", size = 4162562, upload-time = "2025-02-11T15:49:39.541Z" }, - { url = "https://files.pythonhosted.org/packages/c5/1d/5b77815e7d9cf1e3166988647f336f87d5634a5ccecec2ffbe08ef8dd481/cryptography-44.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:322eb03ecc62784536bc173f1483e76747aafeb69c8728df48537eb431cd1911", size = 3951923, upload-time = "2025-02-11T15:49:42.461Z" }, - { url = "https://files.pythonhosted.org/packages/28/01/604508cd34a4024467cd4105887cf27da128cba3edd435b54e2395064bfb/cryptography-44.0.1-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:21377472ca4ada2906bc313168c9dc7b1d7ca417b63c1c3011d0c74b7de9ae69", size = 3685194, upload-time = "2025-02-11T15:49:45.226Z" }, - { url = "https://files.pythonhosted.org/packages/c6/3d/d3c55d4f1d24580a236a6753902ef6d8aafd04da942a1ee9efb9dc8fd0cb/cryptography-44.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:df978682c1504fc93b3209de21aeabf2375cb1571d4e61907b3e7a2540e83026", size = 4187790, upload-time = "2025-02-11T15:49:48.215Z" }, - { url = "https://files.pythonhosted.org/packages/ea/a6/44d63950c8588bfa8594fd234d3d46e93c3841b8e84a066649c566afb972/cryptography-44.0.1-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:eb3889330f2a4a148abead555399ec9a32b13b7c8ba969b72d8e500eb7ef84cd", size = 3951343, upload-time = "2025-02-11T15:49:50.313Z" }, - { url = "https://files.pythonhosted.org/packages/c1/17/f5282661b57301204cbf188254c1a0267dbd8b18f76337f0a7ce1038888c/cryptography-44.0.1-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8e6a85a93d0642bd774460a86513c5d9d80b5c002ca9693e63f6e540f1815ed0", size = 4187127, upload-time = "2025-02-11T15:49:52.051Z" }, - { url = "https://files.pythonhosted.org/packages/f3/68/abbae29ed4f9d96596687f3ceea8e233f65c9645fbbec68adb7c756bb85a/cryptography-44.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6f76fdd6fd048576a04c5210d53aa04ca34d2ed63336d4abd306d0cbe298fddf", size = 4070666, upload-time = "2025-02-11T15:49:56.56Z" }, - { url = "https://files.pythonhosted.org/packages/0f/10/cf91691064a9e0a88ae27e31779200b1505d3aee877dbe1e4e0d73b4f155/cryptography-44.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6c8acf6f3d1f47acb2248ec3ea261171a671f3d9428e34ad0357148d492c7864", size = 4288811, upload-time = "2025-02-11T15:49:59.248Z" }, - { url = "https://files.pythonhosted.org/packages/38/78/74ea9eb547d13c34e984e07ec8a473eb55b19c1451fe7fc8077c6a4b0548/cryptography-44.0.1-cp37-abi3-win32.whl", hash = "sha256:24979e9f2040c953a94bf3c6782e67795a4c260734e5264dceea65c8f4bae64a", size = 2771882, upload-time = "2025-02-11T15:50:01.478Z" }, - { url = "https://files.pythonhosted.org/packages/cf/6c/3907271ee485679e15c9f5e93eac6aa318f859b0aed8d369afd636fafa87/cryptography-44.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:fd0ee90072861e276b0ff08bd627abec29e32a53b2be44e41dbcdf87cbee2b00", size = 3206989, upload-time = "2025-02-11T15:50:03.312Z" }, - { url = "https://files.pythonhosted.org/packages/9f/f1/676e69c56a9be9fd1bffa9bc3492366901f6e1f8f4079428b05f1414e65c/cryptography-44.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a2d8a7045e1ab9b9f803f0d9531ead85f90c5f2859e653b61497228b18452008", size = 6643714, upload-time = "2025-02-11T15:50:05.555Z" }, - { url = "https://files.pythonhosted.org/packages/ba/9f/1775600eb69e72d8f9931a104120f2667107a0ee478f6ad4fe4001559345/cryptography-44.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8272f257cf1cbd3f2e120f14c68bff2b6bdfcc157fafdee84a1b795efd72862", size = 3943269, upload-time = "2025-02-11T15:50:08.54Z" }, - { url = "https://files.pythonhosted.org/packages/25/ba/e00d5ad6b58183829615be7f11f55a7b6baa5a06910faabdc9961527ba44/cryptography-44.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e8d181e90a777b63f3f0caa836844a1182f1f265687fac2115fcf245f5fbec3", size = 4166461, upload-time = "2025-02-11T15:50:11.419Z" }, - { url = "https://files.pythonhosted.org/packages/b3/45/690a02c748d719a95ab08b6e4decb9d81e0ec1bac510358f61624c86e8a3/cryptography-44.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:436df4f203482f41aad60ed1813811ac4ab102765ecae7a2bbb1dbb66dcff5a7", size = 3950314, upload-time = "2025-02-11T15:50:14.181Z" }, - { url = "https://files.pythonhosted.org/packages/e6/50/bf8d090911347f9b75adc20f6f6569ed6ca9b9bff552e6e390f53c2a1233/cryptography-44.0.1-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4f422e8c6a28cf8b7f883eb790695d6d45b0c385a2583073f3cec434cc705e1a", size = 3686675, upload-time = "2025-02-11T15:50:16.3Z" }, - { url = "https://files.pythonhosted.org/packages/e1/e7/cfb18011821cc5f9b21efb3f94f3241e3a658d267a3bf3a0f45543858ed8/cryptography-44.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:72198e2b5925155497a5a3e8c216c7fb3e64c16ccee11f0e7da272fa93b35c4c", size = 4190429, upload-time = "2025-02-11T15:50:19.302Z" }, - { url = "https://files.pythonhosted.org/packages/07/ef/77c74d94a8bfc1a8a47b3cafe54af3db537f081742ee7a8a9bd982b62774/cryptography-44.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a46a89ad3e6176223b632056f321bc7de36b9f9b93b2cc1cccf935a3849dc62", size = 3950039, upload-time = "2025-02-11T15:50:22.257Z" }, - { url = "https://files.pythonhosted.org/packages/6d/b9/8be0ff57c4592382b77406269b1e15650c9f1a167f9e34941b8515b97159/cryptography-44.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:53f23339864b617a3dfc2b0ac8d5c432625c80014c25caac9082314e9de56f41", size = 4189713, upload-time = "2025-02-11T15:50:24.261Z" }, - { url = "https://files.pythonhosted.org/packages/78/e1/4b6ac5f4100545513b0847a4d276fe3c7ce0eacfa73e3b5ebd31776816ee/cryptography-44.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:888fcc3fce0c888785a4876ca55f9f43787f4c5c1cc1e2e0da71ad481ff82c5b", size = 4071193, upload-time = "2025-02-11T15:50:26.18Z" }, - { url = "https://files.pythonhosted.org/packages/3d/cb/afff48ceaed15531eab70445abe500f07f8f96af2bb35d98af6bfa89ebd4/cryptography-44.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:00918d859aa4e57db8299607086f793fa7813ae2ff5a4637e318a25ef82730f7", size = 4289566, upload-time = "2025-02-11T15:50:28.221Z" }, - { url = "https://files.pythonhosted.org/packages/30/6f/4eca9e2e0f13ae459acd1ca7d9f0257ab86e68f44304847610afcb813dc9/cryptography-44.0.1-cp39-abi3-win32.whl", hash = "sha256:9b336599e2cb77b1008cb2ac264b290803ec5e8e89d618a5e978ff5eb6f715d9", size = 2772371, upload-time = "2025-02-11T15:50:29.997Z" }, - { url = "https://files.pythonhosted.org/packages/d2/05/5533d30f53f10239616a357f080892026db2d550a40c393d0a8a7af834a9/cryptography-44.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:e403f7f766ded778ecdb790da786b418a9f2394f36e8cc8b796cc056ab05f44f", size = 3207303, upload-time = "2025-02-11T15:50:32.258Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/d6/0d/d13399c94234ee8f3df384819dc67e0c5ce215fb751d567a55a1f4b028c7/cryptography-45.0.6.tar.gz", hash = "sha256:5c966c732cf6e4a276ce83b6e4c729edda2df6929083a952cc7da973c539c719", size = 744949, upload-time = "2025-08-05T23:59:27.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/29/2793d178d0eda1ca4a09a7c4e09a5185e75738cc6d526433e8663b460ea6/cryptography-45.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:048e7ad9e08cf4c0ab07ff7f36cc3115924e22e2266e034450a890d9e312dd74", size = 7042702, upload-time = "2025-08-05T23:58:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b6/cabd07410f222f32c8d55486c464f432808abaa1f12af9afcbe8f2f19030/cryptography-45.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:44647c5d796f5fc042bbc6d61307d04bf29bccb74d188f18051b635f20a9c75f", size = 4206483, upload-time = "2025-08-05T23:58:27.132Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9e/f9c7d36a38b1cfeb1cc74849aabe9bf817990f7603ff6eb485e0d70e0b27/cryptography-45.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e40b80ecf35ec265c452eea0ba94c9587ca763e739b8e559c128d23bff7ebbbf", size = 4429679, upload-time = "2025-08-05T23:58:29.152Z" }, + { url = "https://files.pythonhosted.org/packages/9c/2a/4434c17eb32ef30b254b9e8b9830cee4e516f08b47fdd291c5b1255b8101/cryptography-45.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:00e8724bdad672d75e6f069b27970883179bd472cd24a63f6e620ca7e41cc0c5", size = 4210553, upload-time = "2025-08-05T23:58:30.596Z" }, + { url = "https://files.pythonhosted.org/packages/ef/1d/09a5df8e0c4b7970f5d1f3aff1b640df6d4be28a64cae970d56c6cf1c772/cryptography-45.0.6-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a3085d1b319d35296176af31c90338eeb2ddac8104661df79f80e1d9787b8b2", size = 3894499, upload-time = "2025-08-05T23:58:32.03Z" }, + { url = "https://files.pythonhosted.org/packages/79/62/120842ab20d9150a9d3a6bdc07fe2870384e82f5266d41c53b08a3a96b34/cryptography-45.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1b7fa6a1c1188c7ee32e47590d16a5a0646270921f8020efc9a511648e1b2e08", size = 4458484, upload-time = "2025-08-05T23:58:33.526Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/1bc3634d45ddfed0871bfba52cf8f1ad724761662a0c792b97a951fb1b30/cryptography-45.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:275ba5cc0d9e320cd70f8e7b96d9e59903c815ca579ab96c1e37278d231fc402", size = 4210281, upload-time = "2025-08-05T23:58:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/ffb12c2d83d0ee625f124880a1f023b5878f79da92e64c37962bbbe35f3f/cryptography-45.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f4028f29a9f38a2025abedb2e409973709c660d44319c61762202206ed577c42", size = 4456890, upload-time = "2025-08-05T23:58:36.923Z" }, + { url = "https://files.pythonhosted.org/packages/8c/8e/b3f3fe0dc82c77a0deb5f493b23311e09193f2268b77196ec0f7a36e3f3e/cryptography-45.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ee411a1b977f40bd075392c80c10b58025ee5c6b47a822a33c1198598a7a5f05", size = 4333247, upload-time = "2025-08-05T23:58:38.781Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a6/c3ef2ab9e334da27a1d7b56af4a2417d77e7806b2e0f90d6267ce120d2e4/cryptography-45.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e2a21a8eda2d86bb604934b6b37691585bd095c1f788530c1fcefc53a82b3453", size = 4565045, upload-time = "2025-08-05T23:58:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/31/c3/77722446b13fa71dddd820a5faab4ce6db49e7e0bf8312ef4192a3f78e2f/cryptography-45.0.6-cp311-abi3-win32.whl", hash = "sha256:d063341378d7ee9c91f9d23b431a3502fc8bfacd54ef0a27baa72a0843b29159", size = 2928923, upload-time = "2025-08-05T23:58:41.919Z" }, + { url = "https://files.pythonhosted.org/packages/38/63/a025c3225188a811b82932a4dcc8457a26c3729d81578ccecbcce2cb784e/cryptography-45.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:833dc32dfc1e39b7376a87b9a6a4288a10aae234631268486558920029b086ec", size = 3403805, upload-time = "2025-08-05T23:58:43.792Z" }, + { url = "https://files.pythonhosted.org/packages/5b/af/bcfbea93a30809f126d51c074ee0fac5bd9d57d068edf56c2a73abedbea4/cryptography-45.0.6-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:3436128a60a5e5490603ab2adbabc8763613f638513ffa7d311c900a8349a2a0", size = 7020111, upload-time = "2025-08-05T23:58:45.316Z" }, + { url = "https://files.pythonhosted.org/packages/98/c6/ea5173689e014f1a8470899cd5beeb358e22bb3cf5a876060f9d1ca78af4/cryptography-45.0.6-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0d9ef57b6768d9fa58e92f4947cea96ade1233c0e236db22ba44748ffedca394", size = 4198169, upload-time = "2025-08-05T23:58:47.121Z" }, + { url = "https://files.pythonhosted.org/packages/ba/73/b12995edc0c7e2311ffb57ebd3b351f6b268fed37d93bfc6f9856e01c473/cryptography-45.0.6-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea3c42f2016a5bbf71825537c2ad753f2870191134933196bee408aac397b3d9", size = 4421273, upload-time = "2025-08-05T23:58:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/f7/6e/286894f6f71926bc0da67408c853dd9ba953f662dcb70993a59fd499f111/cryptography-45.0.6-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:20ae4906a13716139d6d762ceb3e0e7e110f7955f3bc3876e3a07f5daadec5f3", size = 4199211, upload-time = "2025-08-05T23:58:50.139Z" }, + { url = "https://files.pythonhosted.org/packages/de/34/a7f55e39b9623c5cb571d77a6a90387fe557908ffc44f6872f26ca8ae270/cryptography-45.0.6-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dac5ec199038b8e131365e2324c03d20e97fe214af051d20c49db129844e8b3", size = 3883732, upload-time = "2025-08-05T23:58:52.253Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b9/c6d32edbcba0cd9f5df90f29ed46a65c4631c4fbe11187feb9169c6ff506/cryptography-45.0.6-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:18f878a34b90d688982e43f4b700408b478102dd58b3e39de21b5ebf6509c301", size = 4450655, upload-time = "2025-08-05T23:58:53.848Z" }, + { url = "https://files.pythonhosted.org/packages/77/2d/09b097adfdee0227cfd4c699b3375a842080f065bab9014248933497c3f9/cryptography-45.0.6-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5bd6020c80c5b2b2242d6c48487d7b85700f5e0038e67b29d706f98440d66eb5", size = 4198956, upload-time = "2025-08-05T23:58:55.209Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/061ec6689207d54effdff535bbdf85cc380d32dd5377173085812565cf38/cryptography-45.0.6-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:eccddbd986e43014263eda489abbddfbc287af5cddfd690477993dbb31e31016", size = 4449859, upload-time = "2025-08-05T23:58:56.639Z" }, + { url = "https://files.pythonhosted.org/packages/41/ff/e7d5a2ad2d035e5a2af116e1a3adb4d8fcd0be92a18032917a089c6e5028/cryptography-45.0.6-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:550ae02148206beb722cfe4ef0933f9352bab26b087af00e48fdfb9ade35c5b3", size = 4320254, upload-time = "2025-08-05T23:58:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/82/27/092d311af22095d288f4db89fcaebadfb2f28944f3d790a4cf51fe5ddaeb/cryptography-45.0.6-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5b64e668fc3528e77efa51ca70fadcd6610e8ab231e3e06ae2bab3b31c2b8ed9", size = 4554815, upload-time = "2025-08-05T23:59:00.283Z" }, + { url = "https://files.pythonhosted.org/packages/7e/01/aa2f4940262d588a8fdf4edabe4cda45854d00ebc6eaac12568b3a491a16/cryptography-45.0.6-cp37-abi3-win32.whl", hash = "sha256:780c40fb751c7d2b0c6786ceee6b6f871e86e8718a8ff4bc35073ac353c7cd02", size = 2912147, upload-time = "2025-08-05T23:59:01.716Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bc/16e0276078c2de3ceef6b5a34b965f4436215efac45313df90d55f0ba2d2/cryptography-45.0.6-cp37-abi3-win_amd64.whl", hash = "sha256:20d15aed3ee522faac1a39fbfdfee25d17b1284bafd808e1640a74846d7c4d1b", size = 3390459, upload-time = "2025-08-05T23:59:03.358Z" }, + { url = "https://files.pythonhosted.org/packages/61/69/c252de4ec047ba2f567ecb53149410219577d408c2aea9c989acae7eafce/cryptography-45.0.6-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:fc022c1fa5acff6def2fc6d7819bbbd31ccddfe67d075331a65d9cfb28a20983", size = 3584669, upload-time = "2025-08-05T23:59:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/e3/fe/deea71e9f310a31fe0a6bfee670955152128d309ea2d1c79e2a5ae0f0401/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:3de77e4df42ac8d4e4d6cdb342d989803ad37707cf8f3fbf7b088c9cbdd46427", size = 4153022, upload-time = "2025-08-05T23:59:16.954Z" }, + { url = "https://files.pythonhosted.org/packages/60/45/a77452f5e49cb580feedba6606d66ae7b82c128947aa754533b3d1bd44b0/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:599c8d7df950aa68baa7e98f7b73f4f414c9f02d0e8104a30c0182a07732638b", size = 4386802, upload-time = "2025-08-05T23:59:18.55Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b9/a2f747d2acd5e3075fdf5c145c7c3568895daaa38b3b0c960ef830db6cdc/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:31a2b9a10530a1cb04ffd6aa1cd4d3be9ed49f7d77a4dafe198f3b382f41545c", size = 4152706, upload-time = "2025-08-05T23:59:20.044Z" }, + { url = "https://files.pythonhosted.org/packages/81/ec/381b3e8d0685a3f3f304a382aa3dfce36af2d76467da0fd4bb21ddccc7b2/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:e5b3dda1b00fb41da3af4c5ef3f922a200e33ee5ba0f0bc9ecf0b0c173958385", size = 4386740, upload-time = "2025-08-05T23:59:21.525Z" }, + { url = "https://files.pythonhosted.org/packages/0a/76/cf8d69da8d0b5ecb0db406f24a63a3f69ba5e791a11b782aeeefef27ccbb/cryptography-45.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:629127cfdcdc6806dfe234734d7cb8ac54edaf572148274fa377a7d3405b0043", size = 3331874, upload-time = "2025-08-05T23:59:23.017Z" }, ] [[package]] @@ -627,16 +638,16 @@ dev = [{ name = "pyright", specifier = ">=1.1.389" }] [[package]] name = "fastapi" -version = "0.115.8" +version = "0.115.14" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/b2/5a5dc4affdb6661dea100324e19a7721d5dc524b464fe8e366c093fd7d87/fastapi-0.115.8.tar.gz", hash = "sha256:0ce9111231720190473e222cdf0f07f7206ad7e53ea02beb1d2dc36e2f0741e9", size = 295403, upload-time = "2025-01-30T14:06:41.138Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/53/8c38a874844a8b0fa10dd8adf3836ac154082cf88d3f22b544e9ceea0a15/fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739", size = 296263, upload-time = "2025-06-26T15:29:08.21Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/7d/2d6ce181d7a5f51dedb8c06206cbf0ec026a99bf145edd309f9e17c3282f/fastapi-0.115.8-py3-none-any.whl", hash = "sha256:753a96dd7e036b34eeef8babdfcfe3f28ff79648f86551eb36bfc1b0bf4a8cbf", size = 94814, upload-time = "2025-01-30T14:06:38.564Z" }, + { url = "https://files.pythonhosted.org/packages/53/50/b1222562c6d270fea83e9c9075b8e8600b8479150a18e4516a6138b980d1/fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca", size = 95514, upload-time = "2025-06-26T15:29:06.49Z" }, ] [package.optional-dependencies] @@ -651,109 +662,151 @@ standard = [ [[package]] name = "fastapi-cli" -version = "0.0.7" +version = "0.0.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "rich-toolkit" }, { name = "typer" }, { name = "uvicorn", extra = ["standard"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fe/73/82a5831fbbf8ed75905bacf5b2d9d3dfd6f04d6968b29fe6f72a5ae9ceb1/fastapi_cli-0.0.7.tar.gz", hash = "sha256:02b3b65956f526412515907a0793c9094abd4bfb5457b389f645b0ea6ba3605e", size = 16753, upload-time = "2024-12-15T14:28:10.028Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/94/3ef75d9c7c32936ecb539b9750ccbdc3d2568efd73b1cb913278375f4533/fastapi_cli-0.0.8.tar.gz", hash = "sha256:2360f2989b1ab4a3d7fc8b3a0b20e8288680d8af2e31de7c38309934d7f8a0ee", size = 16884, upload-time = "2025-07-07T14:44:09.326Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/e6/5daefc851b514ce2287d8f5d358ae4341089185f78f3217a69d0ce3a390c/fastapi_cli-0.0.7-py3-none-any.whl", hash = "sha256:d549368ff584b2804336c61f192d86ddea080c11255f375959627911944804f4", size = 10705, upload-time = "2024-12-15T14:28:06.18Z" }, + { url = "https://files.pythonhosted.org/packages/e0/3f/6ad3103c5f59208baf4c798526daea6a74085bb35d1c161c501863470476/fastapi_cli-0.0.8-py3-none-any.whl", hash = "sha256:0ea95d882c85b9219a75a65ab27e8da17dac02873e456850fa0a726e96e985eb", size = 10770, upload-time = "2025-07-07T14:44:08.255Z" }, ] [package.optional-dependencies] standard = [ + { name = "fastapi-cloud-cli" }, { name = "uvicorn", extra = ["standard"] }, ] +[[package]] +name = "fastapi-cloud-cli" +version = "0.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic", extra = ["email"] }, + { name = "rich-toolkit" }, + { name = "rignore" }, + { name = "sentry-sdk" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/2e/3b6e5016affc310e5109bc580f760586eabecea0c8a7ab067611cd849ac0/fastapi_cloud_cli-0.1.5.tar.gz", hash = "sha256:341ee585eb731a6d3c3656cb91ad38e5f39809bf1a16d41de1333e38635a7937", size = 22710, upload-time = "2025-07-28T13:30:48.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/a6/5aa862489a2918a096166fd98d9fe86b7fd53c607678b3fa9d8c432d88d5/fastapi_cloud_cli-0.1.5-py3-none-any.whl", hash = "sha256:d80525fb9c0e8af122370891f9fa83cf5d496e4ad47a8dd26c0496a6c85a012a", size = 18992, upload-time = "2025-07-28T13:30:47.427Z" }, +] + [[package]] name = "frozenlist" -version = "1.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8f/ed/0f4cec13a93c02c47ec32d81d11c0c1efbadf4a471e3f3ce7cad366cbbd3/frozenlist-1.5.0.tar.gz", hash = "sha256:81d5af29e61b9c8348e876d442253723928dce6433e0e76cd925cd83f1b4b817", size = 39930, upload-time = "2024-10-23T09:48:29.903Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/43/0bed28bf5eb1c9e4301003b74453b8e7aa85fb293b31dde352aac528dafc/frozenlist-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fd74520371c3c4175142d02a976aee0b4cb4a7cc912a60586ffd8d5929979b30", size = 94987, upload-time = "2024-10-23T09:46:40.487Z" }, - { url = "https://files.pythonhosted.org/packages/bb/bf/b74e38f09a246e8abbe1e90eb65787ed745ccab6eaa58b9c9308e052323d/frozenlist-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f3f7a0fbc219fb4455264cae4d9f01ad41ae6ee8524500f381de64ffaa077d5", size = 54584, upload-time = "2024-10-23T09:46:41.463Z" }, - { url = "https://files.pythonhosted.org/packages/2c/31/ab01375682f14f7613a1ade30149f684c84f9b8823a4391ed950c8285656/frozenlist-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f47c9c9028f55a04ac254346e92977bf0f166c483c74b4232bee19a6697e4778", size = 52499, upload-time = "2024-10-23T09:46:42.451Z" }, - { url = "https://files.pythonhosted.org/packages/98/a8/d0ac0b9276e1404f58fec3ab6e90a4f76b778a49373ccaf6a563f100dfbc/frozenlist-1.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0996c66760924da6e88922756d99b47512a71cfd45215f3570bf1e0b694c206a", size = 276357, upload-time = "2024-10-23T09:46:44.166Z" }, - { url = "https://files.pythonhosted.org/packages/ad/c9/c7761084fa822f07dac38ac29f841d4587570dd211e2262544aa0b791d21/frozenlist-1.5.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2fe128eb4edeabe11896cb6af88fca5346059f6c8d807e3b910069f39157869", size = 287516, upload-time = "2024-10-23T09:46:45.369Z" }, - { url = "https://files.pythonhosted.org/packages/a1/ff/cd7479e703c39df7bdab431798cef89dc75010d8aa0ca2514c5b9321db27/frozenlist-1.5.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a8ea951bbb6cacd492e3948b8da8c502a3f814f5d20935aae74b5df2b19cf3d", size = 283131, upload-time = "2024-10-23T09:46:46.654Z" }, - { url = "https://files.pythonhosted.org/packages/59/a0/370941beb47d237eca4fbf27e4e91389fd68699e6f4b0ebcc95da463835b/frozenlist-1.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:de537c11e4aa01d37db0d403b57bd6f0546e71a82347a97c6a9f0dcc532b3a45", size = 261320, upload-time = "2024-10-23T09:46:47.825Z" }, - { url = "https://files.pythonhosted.org/packages/b8/5f/c10123e8d64867bc9b4f2f510a32042a306ff5fcd7e2e09e5ae5100ee333/frozenlist-1.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c2623347b933fcb9095841f1cc5d4ff0b278addd743e0e966cb3d460278840d", size = 274877, upload-time = "2024-10-23T09:46:48.989Z" }, - { url = "https://files.pythonhosted.org/packages/fa/79/38c505601ae29d4348f21706c5d89755ceded02a745016ba2f58bd5f1ea6/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cee6798eaf8b1416ef6909b06f7dc04b60755206bddc599f52232606e18179d3", size = 269592, upload-time = "2024-10-23T09:46:50.235Z" }, - { url = "https://files.pythonhosted.org/packages/19/e2/39f3a53191b8204ba9f0bb574b926b73dd2efba2a2b9d2d730517e8f7622/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f5f9da7f5dbc00a604fe74aa02ae7c98bcede8a3b8b9666f9f86fc13993bc71a", size = 265934, upload-time = "2024-10-23T09:46:51.829Z" }, - { url = "https://files.pythonhosted.org/packages/d5/c9/3075eb7f7f3a91f1a6b00284af4de0a65a9ae47084930916f5528144c9dd/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:90646abbc7a5d5c7c19461d2e3eeb76eb0b204919e6ece342feb6032c9325ae9", size = 283859, upload-time = "2024-10-23T09:46:52.947Z" }, - { url = "https://files.pythonhosted.org/packages/05/f5/549f44d314c29408b962fa2b0e69a1a67c59379fb143b92a0a065ffd1f0f/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bdac3c7d9b705d253b2ce370fde941836a5f8b3c5c2b8fd70940a3ea3af7f4f2", size = 287560, upload-time = "2024-10-23T09:46:54.162Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f8/cb09b3c24a3eac02c4c07a9558e11e9e244fb02bf62c85ac2106d1eb0c0b/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03d33c2ddbc1816237a67f66336616416e2bbb6beb306e5f890f2eb22b959cdf", size = 277150, upload-time = "2024-10-23T09:46:55.361Z" }, - { url = "https://files.pythonhosted.org/packages/37/48/38c2db3f54d1501e692d6fe058f45b6ad1b358d82cd19436efab80cfc965/frozenlist-1.5.0-cp311-cp311-win32.whl", hash = "sha256:237f6b23ee0f44066219dae14c70ae38a63f0440ce6750f868ee08775073f942", size = 45244, upload-time = "2024-10-23T09:46:56.578Z" }, - { url = "https://files.pythonhosted.org/packages/ca/8c/2ddffeb8b60a4bce3b196c32fcc30d8830d4615e7b492ec2071da801b8ad/frozenlist-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:0cc974cc93d32c42e7b0f6cf242a6bd941c57c61b618e78b6c0a96cb72788c1d", size = 51634, upload-time = "2024-10-23T09:46:57.6Z" }, - { url = "https://files.pythonhosted.org/packages/79/73/fa6d1a96ab7fd6e6d1c3500700963eab46813847f01ef0ccbaa726181dd5/frozenlist-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:31115ba75889723431aa9a4e77d5f398f5cf976eea3bdf61749731f62d4a4a21", size = 94026, upload-time = "2024-10-23T09:46:58.601Z" }, - { url = "https://files.pythonhosted.org/packages/ab/04/ea8bf62c8868b8eada363f20ff1b647cf2e93377a7b284d36062d21d81d1/frozenlist-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7437601c4d89d070eac8323f121fcf25f88674627505334654fd027b091db09d", size = 54150, upload-time = "2024-10-23T09:46:59.608Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9a/8e479b482a6f2070b26bda572c5e6889bb3ba48977e81beea35b5ae13ece/frozenlist-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7948140d9f8ece1745be806f2bfdf390127cf1a763b925c4a805c603df5e697e", size = 51927, upload-time = "2024-10-23T09:47:00.625Z" }, - { url = "https://files.pythonhosted.org/packages/e3/12/2aad87deb08a4e7ccfb33600871bbe8f0e08cb6d8224371387f3303654d7/frozenlist-1.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feeb64bc9bcc6b45c6311c9e9b99406660a9c05ca8a5b30d14a78555088b0b3a", size = 282647, upload-time = "2024-10-23T09:47:01.992Z" }, - { url = "https://files.pythonhosted.org/packages/77/f2/07f06b05d8a427ea0060a9cef6e63405ea9e0d761846b95ef3fb3be57111/frozenlist-1.5.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:683173d371daad49cffb8309779e886e59c2f369430ad28fe715f66d08d4ab1a", size = 289052, upload-time = "2024-10-23T09:47:04.039Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9f/8bf45a2f1cd4aa401acd271b077989c9267ae8463e7c8b1eb0d3f561b65e/frozenlist-1.5.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7d57d8f702221405a9d9b40f9da8ac2e4a1a8b5285aac6100f3393675f0a85ee", size = 291719, upload-time = "2024-10-23T09:47:05.58Z" }, - { url = "https://files.pythonhosted.org/packages/41/d1/1f20fd05a6c42d3868709b7604c9f15538a29e4f734c694c6bcfc3d3b935/frozenlist-1.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30c72000fbcc35b129cb09956836c7d7abf78ab5416595e4857d1cae8d6251a6", size = 267433, upload-time = "2024-10-23T09:47:07.807Z" }, - { url = "https://files.pythonhosted.org/packages/af/f2/64b73a9bb86f5a89fb55450e97cd5c1f84a862d4ff90d9fd1a73ab0f64a5/frozenlist-1.5.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:000a77d6034fbad9b6bb880f7ec073027908f1b40254b5d6f26210d2dab1240e", size = 283591, upload-time = "2024-10-23T09:47:09.645Z" }, - { url = "https://files.pythonhosted.org/packages/29/e2/ffbb1fae55a791fd6c2938dd9ea779509c977435ba3940b9f2e8dc9d5316/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5d7f5a50342475962eb18b740f3beecc685a15b52c91f7d975257e13e029eca9", size = 273249, upload-time = "2024-10-23T09:47:10.808Z" }, - { url = "https://files.pythonhosted.org/packages/2e/6e/008136a30798bb63618a114b9321b5971172a5abddff44a100c7edc5ad4f/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:87f724d055eb4785d9be84e9ebf0f24e392ddfad00b3fe036e43f489fafc9039", size = 271075, upload-time = "2024-10-23T09:47:11.938Z" }, - { url = "https://files.pythonhosted.org/packages/ae/f0/4e71e54a026b06724cec9b6c54f0b13a4e9e298cc8db0f82ec70e151f5ce/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:6e9080bb2fb195a046e5177f10d9d82b8a204c0736a97a153c2466127de87784", size = 285398, upload-time = "2024-10-23T09:47:14.071Z" }, - { url = "https://files.pythonhosted.org/packages/4d/36/70ec246851478b1c0b59f11ef8ade9c482ff447c1363c2bd5fad45098b12/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b93d7aaa36c966fa42efcaf716e6b3900438632a626fb09c049f6a2f09fc631", size = 294445, upload-time = "2024-10-23T09:47:15.318Z" }, - { url = "https://files.pythonhosted.org/packages/37/e0/47f87544055b3349b633a03c4d94b405956cf2437f4ab46d0928b74b7526/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:52ef692a4bc60a6dd57f507429636c2af8b6046db8b31b18dac02cbc8f507f7f", size = 280569, upload-time = "2024-10-23T09:47:17.149Z" }, - { url = "https://files.pythonhosted.org/packages/f9/7c/490133c160fb6b84ed374c266f42800e33b50c3bbab1652764e6e1fc498a/frozenlist-1.5.0-cp312-cp312-win32.whl", hash = "sha256:29d94c256679247b33a3dc96cce0f93cbc69c23bf75ff715919332fdbb6a32b8", size = 44721, upload-time = "2024-10-23T09:47:19.012Z" }, - { url = "https://files.pythonhosted.org/packages/b1/56/4e45136ffc6bdbfa68c29ca56ef53783ef4c2fd395f7cbf99a2624aa9aaa/frozenlist-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:8969190d709e7c48ea386db202d708eb94bdb29207a1f269bab1196ce0dcca1f", size = 51329, upload-time = "2024-10-23T09:47:20.177Z" }, - { url = "https://files.pythonhosted.org/packages/da/3b/915f0bca8a7ea04483622e84a9bd90033bab54bdf485479556c74fd5eaf5/frozenlist-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7a1a048f9215c90973402e26c01d1cff8a209e1f1b53f72b95c13db61b00f953", size = 91538, upload-time = "2024-10-23T09:47:21.176Z" }, - { url = "https://files.pythonhosted.org/packages/c7/d1/a7c98aad7e44afe5306a2b068434a5830f1470675f0e715abb86eb15f15b/frozenlist-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dd47a5181ce5fcb463b5d9e17ecfdb02b678cca31280639255ce9d0e5aa67af0", size = 52849, upload-time = "2024-10-23T09:47:22.439Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/76f23bf9ab15d5f760eb48701909645f686f9c64fbb8982674c241fbef14/frozenlist-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1431d60b36d15cda188ea222033eec8e0eab488f39a272461f2e6d9e1a8e63c2", size = 50583, upload-time = "2024-10-23T09:47:23.44Z" }, - { url = "https://files.pythonhosted.org/packages/1f/22/462a3dd093d11df623179d7754a3b3269de3b42de2808cddef50ee0f4f48/frozenlist-1.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6482a5851f5d72767fbd0e507e80737f9c8646ae7fd303def99bfe813f76cf7f", size = 265636, upload-time = "2024-10-23T09:47:24.82Z" }, - { url = "https://files.pythonhosted.org/packages/80/cf/e075e407fc2ae7328155a1cd7e22f932773c8073c1fc78016607d19cc3e5/frozenlist-1.5.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:44c49271a937625619e862baacbd037a7ef86dd1ee215afc298a417ff3270608", size = 270214, upload-time = "2024-10-23T09:47:26.156Z" }, - { url = "https://files.pythonhosted.org/packages/a1/58/0642d061d5de779f39c50cbb00df49682832923f3d2ebfb0fedf02d05f7f/frozenlist-1.5.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:12f78f98c2f1c2429d42e6a485f433722b0061d5c0b0139efa64f396efb5886b", size = 273905, upload-time = "2024-10-23T09:47:27.741Z" }, - { url = "https://files.pythonhosted.org/packages/ab/66/3fe0f5f8f2add5b4ab7aa4e199f767fd3b55da26e3ca4ce2cc36698e50c4/frozenlist-1.5.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce3aa154c452d2467487765e3adc730a8c153af77ad84096bc19ce19a2400840", size = 250542, upload-time = "2024-10-23T09:47:28.938Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b8/260791bde9198c87a465224e0e2bb62c4e716f5d198fc3a1dacc4895dbd1/frozenlist-1.5.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b7dc0c4338e6b8b091e8faf0db3168a37101943e687f373dce00959583f7439", size = 267026, upload-time = "2024-10-23T09:47:30.283Z" }, - { url = "https://files.pythonhosted.org/packages/2e/a4/3d24f88c527f08f8d44ade24eaee83b2627793fa62fa07cbb7ff7a2f7d42/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:45e0896250900b5aa25180f9aec243e84e92ac84bd4a74d9ad4138ef3f5c97de", size = 257690, upload-time = "2024-10-23T09:47:32.388Z" }, - { url = "https://files.pythonhosted.org/packages/de/9a/d311d660420b2beeff3459b6626f2ab4fb236d07afbdac034a4371fe696e/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:561eb1c9579d495fddb6da8959fd2a1fca2c6d060d4113f5844b433fc02f2641", size = 253893, upload-time = "2024-10-23T09:47:34.274Z" }, - { url = "https://files.pythonhosted.org/packages/c6/23/e491aadc25b56eabd0f18c53bb19f3cdc6de30b2129ee0bc39cd387cd560/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:df6e2f325bfee1f49f81aaac97d2aa757c7646534a06f8f577ce184afe2f0a9e", size = 267006, upload-time = "2024-10-23T09:47:35.499Z" }, - { url = "https://files.pythonhosted.org/packages/08/c4/ab918ce636a35fb974d13d666dcbe03969592aeca6c3ab3835acff01f79c/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:140228863501b44b809fb39ec56b5d4071f4d0aa6d216c19cbb08b8c5a7eadb9", size = 276157, upload-time = "2024-10-23T09:47:37.522Z" }, - { url = "https://files.pythonhosted.org/packages/c0/29/3b7a0bbbbe5a34833ba26f686aabfe982924adbdcafdc294a7a129c31688/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7707a25d6a77f5d27ea7dc7d1fc608aa0a478193823f88511ef5e6b8a48f9d03", size = 264642, upload-time = "2024-10-23T09:47:38.75Z" }, - { url = "https://files.pythonhosted.org/packages/ab/42/0595b3dbffc2e82d7fe658c12d5a5bafcd7516c6bf2d1d1feb5387caa9c1/frozenlist-1.5.0-cp313-cp313-win32.whl", hash = "sha256:31a9ac2b38ab9b5a8933b693db4939764ad3f299fcaa931a3e605bc3460e693c", size = 44914, upload-time = "2024-10-23T09:47:40.145Z" }, - { url = "https://files.pythonhosted.org/packages/17/c4/b7db1206a3fea44bf3b838ca61deb6f74424a8a5db1dd53ecb21da669be6/frozenlist-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:11aabdd62b8b9c4b84081a3c246506d1cddd2dd93ff0ad53ede5defec7886b28", size = 51167, upload-time = "2024-10-23T09:47:41.812Z" }, - { url = "https://files.pythonhosted.org/packages/c6/c8/a5be5b7550c10858fcf9b0ea054baccab474da77d37f1e828ce043a3a5d4/frozenlist-1.5.0-py3-none-any.whl", hash = "sha256:d994863bba198a4a518b467bb971c56e1db3f180a25c6cf7bb1949c267f748c3", size = 11901, upload-time = "2024-10-23T09:48:28.851Z" }, +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload-time = "2025-06-09T23:02:35.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/7e/803dde33760128acd393a27eb002f2020ddb8d99d30a44bfbaab31c5f08a/frozenlist-1.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa51e147a66b2d74de1e6e2cf5921890de6b0f4820b257465101d7f37b49fb5a", size = 82251, upload-time = "2025-06-09T23:00:16.279Z" }, + { url = "https://files.pythonhosted.org/packages/75/a9/9c2c5760b6ba45eae11334db454c189d43d34a4c0b489feb2175e5e64277/frozenlist-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b35db7ce1cd71d36ba24f80f0c9e7cff73a28d7a74e91fe83e23d27c7828750", size = 48183, upload-time = "2025-06-09T23:00:17.698Z" }, + { url = "https://files.pythonhosted.org/packages/47/be/4038e2d869f8a2da165f35a6befb9158c259819be22eeaf9c9a8f6a87771/frozenlist-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34a69a85e34ff37791e94542065c8416c1afbf820b68f720452f636d5fb990cd", size = 47107, upload-time = "2025-06-09T23:00:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/79/26/85314b8a83187c76a37183ceed886381a5f992975786f883472fcb6dc5f2/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a646531fa8d82c87fe4bb2e596f23173caec9185bfbca5d583b4ccfb95183e2", size = 237333, upload-time = "2025-06-09T23:00:20.275Z" }, + { url = "https://files.pythonhosted.org/packages/1f/fd/e5b64f7d2c92a41639ffb2ad44a6a82f347787abc0c7df5f49057cf11770/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79b2ffbba483f4ed36a0f236ccb85fbb16e670c9238313709638167670ba235f", size = 231724, upload-time = "2025-06-09T23:00:21.705Z" }, + { url = "https://files.pythonhosted.org/packages/20/fb/03395c0a43a5976af4bf7534759d214405fbbb4c114683f434dfdd3128ef/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a26f205c9ca5829cbf82bb2a84b5c36f7184c4316617d7ef1b271a56720d6b30", size = 245842, upload-time = "2025-06-09T23:00:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/d0/15/c01c8e1dffdac5d9803507d824f27aed2ba76b6ed0026fab4d9866e82f1f/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bcacfad3185a623fa11ea0e0634aac7b691aa925d50a440f39b458e41c561d98", size = 239767, upload-time = "2025-06-09T23:00:25.103Z" }, + { url = "https://files.pythonhosted.org/packages/14/99/3f4c6fe882c1f5514b6848aa0a69b20cb5e5d8e8f51a339d48c0e9305ed0/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72c1b0fe8fe451b34f12dce46445ddf14bd2a5bcad7e324987194dc8e3a74c86", size = 224130, upload-time = "2025-06-09T23:00:27.061Z" }, + { url = "https://files.pythonhosted.org/packages/4d/83/220a374bd7b2aeba9d0725130665afe11de347d95c3620b9b82cc2fcab97/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61d1a5baeaac6c0798ff6edfaeaa00e0e412d49946c53fae8d4b8e8b3566c4ae", size = 235301, upload-time = "2025-06-09T23:00:29.02Z" }, + { url = "https://files.pythonhosted.org/packages/03/3c/3e3390d75334a063181625343e8daab61b77e1b8214802cc4e8a1bb678fc/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7edf5c043c062462f09b6820de9854bf28cc6cc5b6714b383149745e287181a8", size = 234606, upload-time = "2025-06-09T23:00:30.514Z" }, + { url = "https://files.pythonhosted.org/packages/23/1e/58232c19608b7a549d72d9903005e2d82488f12554a32de2d5fb59b9b1ba/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d50ac7627b3a1bd2dcef6f9da89a772694ec04d9a61b66cf87f7d9446b4a0c31", size = 248372, upload-time = "2025-06-09T23:00:31.966Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/e4a567e01702a88a74ce8a324691e62a629bf47d4f8607f24bf1c7216e7f/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ce48b2fece5aeb45265bb7a58259f45027db0abff478e3077e12b05b17fb9da7", size = 229860, upload-time = "2025-06-09T23:00:33.375Z" }, + { url = "https://files.pythonhosted.org/packages/73/a6/63b3374f7d22268b41a9db73d68a8233afa30ed164c46107b33c4d18ecdd/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fe2365ae915a1fafd982c146754e1de6ab3478def8a59c86e1f7242d794f97d5", size = 245893, upload-time = "2025-06-09T23:00:35.002Z" }, + { url = "https://files.pythonhosted.org/packages/6d/eb/d18b3f6e64799a79673c4ba0b45e4cfbe49c240edfd03a68be20002eaeaa/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:45a6f2fdbd10e074e8814eb98b05292f27bad7d1883afbe009d96abdcf3bc898", size = 246323, upload-time = "2025-06-09T23:00:36.468Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f5/720f3812e3d06cd89a1d5db9ff6450088b8f5c449dae8ffb2971a44da506/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21884e23cffabb157a9dd7e353779077bf5b8f9a58e9b262c6caad2ef5f80a56", size = 233149, upload-time = "2025-06-09T23:00:37.963Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/03efbf545e217d5db8446acfd4c447c15b7c8cf4dbd4a58403111df9322d/frozenlist-1.7.0-cp311-cp311-win32.whl", hash = "sha256:284d233a8953d7b24f9159b8a3496fc1ddc00f4db99c324bd5fb5f22d8698ea7", size = 39565, upload-time = "2025-06-09T23:00:39.753Z" }, + { url = "https://files.pythonhosted.org/packages/58/17/fe61124c5c333ae87f09bb67186d65038834a47d974fc10a5fadb4cc5ae1/frozenlist-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:387cbfdcde2f2353f19c2f66bbb52406d06ed77519ac7ee21be0232147c2592d", size = 44019, upload-time = "2025-06-09T23:00:40.988Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a2/c8131383f1e66adad5f6ecfcce383d584ca94055a34d683bbb24ac5f2f1c/frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2", size = 81424, upload-time = "2025-06-09T23:00:42.24Z" }, + { url = "https://files.pythonhosted.org/packages/4c/9d/02754159955088cb52567337d1113f945b9e444c4960771ea90eb73de8db/frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb", size = 47952, upload-time = "2025-06-09T23:00:43.481Z" }, + { url = "https://files.pythonhosted.org/packages/01/7a/0046ef1bd6699b40acd2067ed6d6670b4db2f425c56980fa21c982c2a9db/frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478", size = 46688, upload-time = "2025-06-09T23:00:44.793Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a2/a910bafe29c86997363fb4c02069df4ff0b5bc39d33c5198b4e9dd42d8f8/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8", size = 243084, upload-time = "2025-06-09T23:00:46.125Z" }, + { url = "https://files.pythonhosted.org/packages/64/3e/5036af9d5031374c64c387469bfcc3af537fc0f5b1187d83a1cf6fab1639/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08", size = 233524, upload-time = "2025-06-09T23:00:47.73Z" }, + { url = "https://files.pythonhosted.org/packages/06/39/6a17b7c107a2887e781a48ecf20ad20f1c39d94b2a548c83615b5b879f28/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4", size = 248493, upload-time = "2025-06-09T23:00:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/be/00/711d1337c7327d88c44d91dd0f556a1c47fb99afc060ae0ef66b4d24793d/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b", size = 244116, upload-time = "2025-06-09T23:00:51.352Z" }, + { url = "https://files.pythonhosted.org/packages/24/fe/74e6ec0639c115df13d5850e75722750adabdc7de24e37e05a40527ca539/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e", size = 224557, upload-time = "2025-06-09T23:00:52.855Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/48421f62a6f77c553575201e89048e97198046b793f4a089c79a6e3268bd/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca", size = 241820, upload-time = "2025-06-09T23:00:54.43Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fa/cb4a76bea23047c8462976ea7b7a2bf53997a0ca171302deae9d6dd12096/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df", size = 236542, upload-time = "2025-06-09T23:00:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/5d/32/476a4b5cfaa0ec94d3f808f193301debff2ea42288a099afe60757ef6282/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5", size = 249350, upload-time = "2025-06-09T23:00:58.468Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ba/9a28042f84a6bf8ea5dbc81cfff8eaef18d78b2a1ad9d51c7bc5b029ad16/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025", size = 225093, upload-time = "2025-06-09T23:01:00.015Z" }, + { url = "https://files.pythonhosted.org/packages/bc/29/3a32959e68f9cf000b04e79ba574527c17e8842e38c91d68214a37455786/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01", size = 245482, upload-time = "2025-06-09T23:01:01.474Z" }, + { url = "https://files.pythonhosted.org/packages/80/e8/edf2f9e00da553f07f5fa165325cfc302dead715cab6ac8336a5f3d0adc2/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08", size = 249590, upload-time = "2025-06-09T23:01:02.961Z" }, + { url = "https://files.pythonhosted.org/packages/1c/80/9a0eb48b944050f94cc51ee1c413eb14a39543cc4f760ed12657a5a3c45a/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43", size = 237785, upload-time = "2025-06-09T23:01:05.095Z" }, + { url = "https://files.pythonhosted.org/packages/f3/74/87601e0fb0369b7a2baf404ea921769c53b7ae00dee7dcfe5162c8c6dbf0/frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3", size = 39487, upload-time = "2025-06-09T23:01:06.54Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/c026e9a9fc17585a9d461f65d8593d281fedf55fbf7eb53f16c6df2392f9/frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a", size = 43874, upload-time = "2025-06-09T23:01:07.752Z" }, + { url = "https://files.pythonhosted.org/packages/24/90/6b2cebdabdbd50367273c20ff6b57a3dfa89bd0762de02c3a1eb42cb6462/frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee", size = 79791, upload-time = "2025-06-09T23:01:09.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/5b70b6a3325363293fe5fc3ae74cdcbc3e996c2a11dde2fd9f1fb0776d19/frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d", size = 47165, upload-time = "2025-06-09T23:01:10.653Z" }, + { url = "https://files.pythonhosted.org/packages/f4/25/a0895c99270ca6966110f4ad98e87e5662eab416a17e7fd53c364bf8b954/frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43", size = 45881, upload-time = "2025-06-09T23:01:12.296Z" }, + { url = "https://files.pythonhosted.org/packages/19/7c/71bb0bbe0832793c601fff68cd0cf6143753d0c667f9aec93d3c323f4b55/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d", size = 232409, upload-time = "2025-06-09T23:01:13.641Z" }, + { url = "https://files.pythonhosted.org/packages/c0/45/ed2798718910fe6eb3ba574082aaceff4528e6323f9a8570be0f7028d8e9/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee", size = 225132, upload-time = "2025-06-09T23:01:15.264Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e2/8417ae0f8eacb1d071d4950f32f229aa6bf68ab69aab797b72a07ea68d4f/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb", size = 237638, upload-time = "2025-06-09T23:01:16.752Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b7/2ace5450ce85f2af05a871b8c8719b341294775a0a6c5585d5e6170f2ce7/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f", size = 233539, upload-time = "2025-06-09T23:01:18.202Z" }, + { url = "https://files.pythonhosted.org/packages/46/b9/6989292c5539553dba63f3c83dc4598186ab2888f67c0dc1d917e6887db6/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60", size = 215646, upload-time = "2025-06-09T23:01:19.649Z" }, + { url = "https://files.pythonhosted.org/packages/72/31/bc8c5c99c7818293458fe745dab4fd5730ff49697ccc82b554eb69f16a24/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00", size = 232233, upload-time = "2025-06-09T23:01:21.175Z" }, + { url = "https://files.pythonhosted.org/packages/59/52/460db4d7ba0811b9ccb85af996019f5d70831f2f5f255f7cc61f86199795/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b", size = 227996, upload-time = "2025-06-09T23:01:23.098Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/f4b39e904c03927b7ecf891804fd3b4df3db29b9e487c6418e37988d6e9d/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c", size = 242280, upload-time = "2025-06-09T23:01:24.808Z" }, + { url = "https://files.pythonhosted.org/packages/b8/33/3f8d6ced42f162d743e3517781566b8481322be321b486d9d262adf70bfb/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949", size = 217717, upload-time = "2025-06-09T23:01:26.28Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e8/ad683e75da6ccef50d0ab0c2b2324b32f84fc88ceee778ed79b8e2d2fe2e/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca", size = 236644, upload-time = "2025-06-09T23:01:27.887Z" }, + { url = "https://files.pythonhosted.org/packages/b2/14/8d19ccdd3799310722195a72ac94ddc677541fb4bef4091d8e7775752360/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b", size = 238879, upload-time = "2025-06-09T23:01:29.524Z" }, + { url = "https://files.pythonhosted.org/packages/ce/13/c12bf657494c2fd1079a48b2db49fa4196325909249a52d8f09bc9123fd7/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e", size = 232502, upload-time = "2025-06-09T23:01:31.287Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8b/e7f9dfde869825489382bc0d512c15e96d3964180c9499efcec72e85db7e/frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1", size = 39169, upload-time = "2025-06-09T23:01:35.503Z" }, + { url = "https://files.pythonhosted.org/packages/35/89/a487a98d94205d85745080a37860ff5744b9820a2c9acbcdd9440bfddf98/frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba", size = 43219, upload-time = "2025-06-09T23:01:36.784Z" }, + { url = "https://files.pythonhosted.org/packages/56/d5/5c4cf2319a49eddd9dd7145e66c4866bdc6f3dbc67ca3d59685149c11e0d/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d", size = 84345, upload-time = "2025-06-09T23:01:38.295Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/ec2c1e1dc16b85bc9d526009961953df9cec8481b6886debb36ec9107799/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d", size = 48880, upload-time = "2025-06-09T23:01:39.887Z" }, + { url = "https://files.pythonhosted.org/packages/69/86/f9596807b03de126e11e7d42ac91e3d0b19a6599c714a1989a4e85eeefc4/frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b", size = 48498, upload-time = "2025-06-09T23:01:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/5e/cb/df6de220f5036001005f2d726b789b2c0b65f2363b104bbc16f5be8084f8/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146", size = 292296, upload-time = "2025-06-09T23:01:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/83/1f/de84c642f17c8f851a2905cee2dae401e5e0daca9b5ef121e120e19aa825/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74", size = 273103, upload-time = "2025-06-09T23:01:44.166Z" }, + { url = "https://files.pythonhosted.org/packages/88/3c/c840bfa474ba3fa13c772b93070893c6e9d5c0350885760376cbe3b6c1b3/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1", size = 292869, upload-time = "2025-06-09T23:01:45.681Z" }, + { url = "https://files.pythonhosted.org/packages/a6/1c/3efa6e7d5a39a1d5ef0abeb51c48fb657765794a46cf124e5aca2c7a592c/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1", size = 291467, upload-time = "2025-06-09T23:01:47.234Z" }, + { url = "https://files.pythonhosted.org/packages/4f/00/d5c5e09d4922c395e2f2f6b79b9a20dab4b67daaf78ab92e7729341f61f6/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384", size = 266028, upload-time = "2025-06-09T23:01:48.819Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/72765be905619dfde25a7f33813ac0341eb6b076abede17a2e3fbfade0cb/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb", size = 284294, upload-time = "2025-06-09T23:01:50.394Z" }, + { url = "https://files.pythonhosted.org/packages/88/67/c94103a23001b17808eb7dd1200c156bb69fb68e63fcf0693dde4cd6228c/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c", size = 281898, upload-time = "2025-06-09T23:01:52.234Z" }, + { url = "https://files.pythonhosted.org/packages/42/34/a3e2c00c00f9e2a9db5653bca3fec306349e71aff14ae45ecc6d0951dd24/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65", size = 290465, upload-time = "2025-06-09T23:01:53.788Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/f89b7fbce8b0b0c095d82b008afd0590f71ccb3dee6eee41791cf8cd25fd/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3", size = 266385, upload-time = "2025-06-09T23:01:55.769Z" }, + { url = "https://files.pythonhosted.org/packages/cd/45/e365fdb554159462ca12df54bc59bfa7a9a273ecc21e99e72e597564d1ae/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657", size = 288771, upload-time = "2025-06-09T23:01:57.4Z" }, + { url = "https://files.pythonhosted.org/packages/00/11/47b6117002a0e904f004d70ec5194fe9144f117c33c851e3d51c765962d0/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104", size = 288206, upload-time = "2025-06-09T23:01:58.936Z" }, + { url = "https://files.pythonhosted.org/packages/40/37/5f9f3c3fd7f7746082ec67bcdc204db72dad081f4f83a503d33220a92973/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf", size = 282620, upload-time = "2025-06-09T23:02:00.493Z" }, + { url = "https://files.pythonhosted.org/packages/0b/31/8fbc5af2d183bff20f21aa743b4088eac4445d2bb1cdece449ae80e4e2d1/frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81", size = 43059, upload-time = "2025-06-09T23:02:02.072Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ed/41956f52105b8dbc26e457c5705340c67c8cc2b79f394b79bffc09d0e938/frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e", size = 47516, upload-time = "2025-06-09T23:02:03.779Z" }, + { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, ] [[package]] name = "h11" -version = "0.14.0" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418, upload-time = "2022-09-25T15:40:01.519Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259, upload-time = "2022-09-25T15:39:59.68Z" }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] [[package]] name = "hishel" -version = "0.1.2" +version = "0.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a8/3d/f754187b9703a8db211e80cef0fc4e27e0ab2edbeacccf1257c096dade48/hishel-0.1.2.tar.gz", hash = "sha256:6643450bfb1cfa2ecd6002769f6f5069d0d048c9c1f1e29a98a48302d5875092", size = 36240, upload-time = "2025-04-04T21:15:57.213Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/b5/c063cd3eab8154ddd61deb07b50497cf24010727eaeec4d78ed1a6262986/hishel-0.1.3.tar.gz", hash = "sha256:db3e07429cb739dcda851ff9b35b0f3e7589e21b90ee167df54336ac608b6ec3", size = 36649, upload-time = "2025-07-06T14:19:23.528Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/11/23/a33edb262bb7732a528595b008c7b3640bea4be7e7417cbaff8f978ead72/hishel-0.1.2-py3-none-any.whl", hash = "sha256:802b4e446017f4867efdb26d3417670991ad1b4826d24331110871fe8957b5d0", size = 42237, upload-time = "2025-04-04T21:15:55.781Z" }, + { url = "https://files.pythonhosted.org/packages/29/a5/bf3553b44a36e1c5d2aa0cd15478e02b466dcaecdc2983b07068999d2675/hishel-0.1.3-py3-none-any.whl", hash = "sha256:bae3ba9970ffc56f90014aea2b3019158fb0a5b0b635a56f414ba6b96651966e", size = 42518, upload-time = "2025-07-06T14:19:22.336Z" }, ] [[package]] name = "httpcore" -version = "1.0.7" +version = "1.0.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196, upload-time = "2024-11-15T12:30:47.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551, upload-time = "2024-11-15T12:30:45.782Z" }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] [[package]] @@ -829,61 +882,74 @@ wheels = [ [[package]] name = "jinja2" -version = "3.1.5" +version = "3.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/af/92/b3130cbbf5591acf9ade8708c365f3238046ac7cb8ccba6e81abccb0ccff/jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb", size = 244674, upload-time = "2024-12-21T18:30:22.828Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/0f/2ba5fbcd631e3e88689309dbe978c5769e883e4b84ebfe7da30b43275c5a/jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb", size = 134596, upload-time = "2024-12-21T18:30:19.133Z" }, + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] [[package]] name = "jiter" -version = "0.8.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/70/90bc7bd3932e651486861df5c8ffea4ca7c77d28e8532ddefe2abc561a53/jiter-0.8.2.tar.gz", hash = "sha256:cd73d3e740666d0e639f678adb176fad25c1bcbdae88d8d7b857e1783bb4212d", size = 163007, upload-time = "2024-12-09T18:11:08.649Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b0/c1a7caa7f9dc5f1f6cfa08722867790fe2d3645d6e7170ca280e6e52d163/jiter-0.8.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:2dd61c5afc88a4fda7d8b2cf03ae5947c6ac7516d32b7a15bf4b49569a5c076b", size = 303666, upload-time = "2024-12-09T18:09:23.145Z" }, - { url = "https://files.pythonhosted.org/packages/f5/97/0468bc9eeae43079aaa5feb9267964e496bf13133d469cfdc135498f8dd0/jiter-0.8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a6c710d657c8d1d2adbbb5c0b0c6bfcec28fd35bd6b5f016395f9ac43e878a15", size = 311934, upload-time = "2024-12-09T18:09:25.098Z" }, - { url = "https://files.pythonhosted.org/packages/e5/69/64058e18263d9a5f1e10f90c436853616d5f047d997c37c7b2df11b085ec/jiter-0.8.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9584de0cd306072635fe4b89742bf26feae858a0683b399ad0c2509011b9dc0", size = 335506, upload-time = "2024-12-09T18:09:26.407Z" }, - { url = "https://files.pythonhosted.org/packages/9d/14/b747f9a77b8c0542141d77ca1e2a7523e854754af2c339ac89a8b66527d6/jiter-0.8.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a90a923338531b7970abb063cfc087eebae6ef8ec8139762007188f6bc69a9f", size = 355849, upload-time = "2024-12-09T18:09:27.686Z" }, - { url = "https://files.pythonhosted.org/packages/53/e2/98a08161db7cc9d0e39bc385415890928ff09709034982f48eccfca40733/jiter-0.8.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d21974d246ed0181558087cd9f76e84e8321091ebfb3a93d4c341479a736f099", size = 381700, upload-time = "2024-12-09T18:09:28.989Z" }, - { url = "https://files.pythonhosted.org/packages/7a/38/1674672954d35bce3b1c9af99d5849f9256ac8f5b672e020ac7821581206/jiter-0.8.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32475a42b2ea7b344069dc1e81445cfc00b9d0e3ca837f0523072432332e9f74", size = 389710, upload-time = "2024-12-09T18:09:30.565Z" }, - { url = "https://files.pythonhosted.org/packages/f8/9b/92f9da9a9e107d019bcf883cd9125fa1690079f323f5a9d5c6986eeec3c0/jiter-0.8.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b9931fd36ee513c26b5bf08c940b0ac875de175341cbdd4fa3be109f0492586", size = 345553, upload-time = "2024-12-09T18:09:32.735Z" }, - { url = "https://files.pythonhosted.org/packages/44/a6/6d030003394e9659cd0d7136bbeabd82e869849ceccddc34d40abbbbb269/jiter-0.8.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce0820f4a3a59ddced7fce696d86a096d5cc48d32a4183483a17671a61edfddc", size = 376388, upload-time = "2024-12-09T18:09:34.723Z" }, - { url = "https://files.pythonhosted.org/packages/ad/8d/87b09e648e4aca5f9af89e3ab3cfb93db2d1e633b2f2931ede8dabd9b19a/jiter-0.8.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:8ffc86ae5e3e6a93765d49d1ab47b6075a9c978a2b3b80f0f32628f39caa0c88", size = 511226, upload-time = "2024-12-09T18:09:36.13Z" }, - { url = "https://files.pythonhosted.org/packages/77/95/8008ebe4cdc82eac1c97864a8042ca7e383ed67e0ec17bfd03797045c727/jiter-0.8.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5127dc1abd809431172bc3fbe8168d6b90556a30bb10acd5ded41c3cfd6f43b6", size = 504134, upload-time = "2024-12-09T18:09:37.581Z" }, - { url = "https://files.pythonhosted.org/packages/26/0d/3056a74de13e8b2562e4d526de6dac2f65d91ace63a8234deb9284a1d24d/jiter-0.8.2-cp311-cp311-win32.whl", hash = "sha256:66227a2c7b575720c1871c8800d3a0122bb8ee94edb43a5685aa9aceb2782d44", size = 203103, upload-time = "2024-12-09T18:09:38.881Z" }, - { url = "https://files.pythonhosted.org/packages/4e/1e/7f96b798f356e531ffc0f53dd2f37185fac60fae4d6c612bbbd4639b90aa/jiter-0.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:cde031d8413842a1e7501e9129b8e676e62a657f8ec8166e18a70d94d4682855", size = 206717, upload-time = "2024-12-09T18:09:41.064Z" }, - { url = "https://files.pythonhosted.org/packages/a1/17/c8747af8ea4e045f57d6cfd6fc180752cab9bc3de0e8a0c9ca4e8af333b1/jiter-0.8.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:e6ec2be506e7d6f9527dae9ff4b7f54e68ea44a0ef6b098256ddf895218a2f8f", size = 302027, upload-time = "2024-12-09T18:09:43.11Z" }, - { url = "https://files.pythonhosted.org/packages/3c/c1/6da849640cd35a41e91085723b76acc818d4b7d92b0b6e5111736ce1dd10/jiter-0.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76e324da7b5da060287c54f2fabd3db5f76468006c811831f051942bf68c9d44", size = 310326, upload-time = "2024-12-09T18:09:44.426Z" }, - { url = "https://files.pythonhosted.org/packages/06/99/a2bf660d8ccffee9ad7ed46b4f860d2108a148d0ea36043fd16f4dc37e94/jiter-0.8.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:180a8aea058f7535d1c84183c0362c710f4750bef66630c05f40c93c2b152a0f", size = 334242, upload-time = "2024-12-09T18:09:45.915Z" }, - { url = "https://files.pythonhosted.org/packages/a7/5f/cea1c17864828731f11427b9d1ab7f24764dbd9aaf4648a7f851164d2718/jiter-0.8.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:025337859077b41548bdcbabe38698bcd93cfe10b06ff66617a48ff92c9aec60", size = 356654, upload-time = "2024-12-09T18:09:47.619Z" }, - { url = "https://files.pythonhosted.org/packages/e9/13/62774b7e5e7f5d5043efe1d0f94ead66e6d0f894ae010adb56b3f788de71/jiter-0.8.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ecff0dc14f409599bbcafa7e470c00b80f17abc14d1405d38ab02e4b42e55b57", size = 379967, upload-time = "2024-12-09T18:09:49.987Z" }, - { url = "https://files.pythonhosted.org/packages/ec/fb/096b34c553bb0bd3f2289d5013dcad6074948b8d55212aa13a10d44c5326/jiter-0.8.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ffd9fee7d0775ebaba131f7ca2e2d83839a62ad65e8e02fe2bd8fc975cedeb9e", size = 389252, upload-time = "2024-12-09T18:09:51.329Z" }, - { url = "https://files.pythonhosted.org/packages/17/61/beea645c0bf398ced8b199e377b61eb999d8e46e053bb285c91c3d3eaab0/jiter-0.8.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14601dcac4889e0a1c75ccf6a0e4baf70dbc75041e51bcf8d0e9274519df6887", size = 345490, upload-time = "2024-12-09T18:09:52.646Z" }, - { url = "https://files.pythonhosted.org/packages/d5/df/834aa17ad5dcc3cf0118821da0a0cf1589ea7db9832589278553640366bc/jiter-0.8.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:92249669925bc1c54fcd2ec73f70f2c1d6a817928480ee1c65af5f6b81cdf12d", size = 376991, upload-time = "2024-12-09T18:09:53.972Z" }, - { url = "https://files.pythonhosted.org/packages/67/80/87d140399d382fb4ea5b3d56e7ecaa4efdca17cd7411ff904c1517855314/jiter-0.8.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e725edd0929fa79f8349ab4ec7f81c714df51dc4e991539a578e5018fa4a7152", size = 510822, upload-time = "2024-12-09T18:09:55.439Z" }, - { url = "https://files.pythonhosted.org/packages/5c/37/3394bb47bac1ad2cb0465601f86828a0518d07828a650722e55268cdb7e6/jiter-0.8.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bf55846c7b7a680eebaf9c3c48d630e1bf51bdf76c68a5f654b8524335b0ad29", size = 503730, upload-time = "2024-12-09T18:09:59.494Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e2/253fc1fa59103bb4e3aa0665d6ceb1818df1cd7bf3eb492c4dad229b1cd4/jiter-0.8.2-cp312-cp312-win32.whl", hash = "sha256:7efe4853ecd3d6110301665a5178b9856be7e2a9485f49d91aa4d737ad2ae49e", size = 203375, upload-time = "2024-12-09T18:10:00.814Z" }, - { url = "https://files.pythonhosted.org/packages/41/69/6d4bbe66b3b3b4507e47aa1dd5d075919ad242b4b1115b3f80eecd443687/jiter-0.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:83c0efd80b29695058d0fd2fa8a556490dbce9804eac3e281f373bbc99045f6c", size = 204740, upload-time = "2024-12-09T18:10:02.146Z" }, - { url = "https://files.pythonhosted.org/packages/6c/b0/bfa1f6f2c956b948802ef5a021281978bf53b7a6ca54bb126fd88a5d014e/jiter-0.8.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:ca1f08b8e43dc3bd0594c992fb1fd2f7ce87f7bf0d44358198d6da8034afdf84", size = 301190, upload-time = "2024-12-09T18:10:03.463Z" }, - { url = "https://files.pythonhosted.org/packages/a4/8f/396ddb4e292b5ea57e45ade5dc48229556b9044bad29a3b4b2dddeaedd52/jiter-0.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5672a86d55416ccd214c778efccf3266b84f87b89063b582167d803246354be4", size = 309334, upload-time = "2024-12-09T18:10:05.774Z" }, - { url = "https://files.pythonhosted.org/packages/7f/68/805978f2f446fa6362ba0cc2e4489b945695940656edd844e110a61c98f8/jiter-0.8.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58dc9bc9767a1101f4e5e22db1b652161a225874d66f0e5cb8e2c7d1c438b587", size = 333918, upload-time = "2024-12-09T18:10:07.158Z" }, - { url = "https://files.pythonhosted.org/packages/b3/99/0f71f7be667c33403fa9706e5b50583ae5106d96fab997fa7e2f38ee8347/jiter-0.8.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:37b2998606d6dadbb5ccda959a33d6a5e853252d921fec1792fc902351bb4e2c", size = 356057, upload-time = "2024-12-09T18:10:09.341Z" }, - { url = "https://files.pythonhosted.org/packages/8d/50/a82796e421a22b699ee4d2ce527e5bcb29471a2351cbdc931819d941a167/jiter-0.8.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4ab9a87f3784eb0e098f84a32670cfe4a79cb6512fd8f42ae3d0709f06405d18", size = 379790, upload-time = "2024-12-09T18:10:10.702Z" }, - { url = "https://files.pythonhosted.org/packages/3c/31/10fb012b00f6d83342ca9e2c9618869ab449f1aa78c8f1b2193a6b49647c/jiter-0.8.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:79aec8172b9e3c6d05fd4b219d5de1ac616bd8da934107325a6c0d0e866a21b6", size = 388285, upload-time = "2024-12-09T18:10:12.721Z" }, - { url = "https://files.pythonhosted.org/packages/c8/81/f15ebf7de57be488aa22944bf4274962aca8092e4f7817f92ffa50d3ee46/jiter-0.8.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:711e408732d4e9a0208008e5892c2966b485c783cd2d9a681f3eb147cf36c7ef", size = 344764, upload-time = "2024-12-09T18:10:14.075Z" }, - { url = "https://files.pythonhosted.org/packages/b3/e8/0cae550d72b48829ba653eb348cdc25f3f06f8a62363723702ec18e7be9c/jiter-0.8.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:653cf462db4e8c41995e33d865965e79641ef45369d8a11f54cd30888b7e6ff1", size = 376620, upload-time = "2024-12-09T18:10:15.487Z" }, - { url = "https://files.pythonhosted.org/packages/b8/50/e5478ff9d82534a944c03b63bc217c5f37019d4a34d288db0f079b13c10b/jiter-0.8.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:9c63eaef32b7bebac8ebebf4dabebdbc6769a09c127294db6babee38e9f405b9", size = 510402, upload-time = "2024-12-09T18:10:17.499Z" }, - { url = "https://files.pythonhosted.org/packages/8e/1e/3de48bbebbc8f7025bd454cedc8c62378c0e32dd483dece5f4a814a5cb55/jiter-0.8.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:eb21aaa9a200d0a80dacc7a81038d2e476ffe473ffdd9c91eb745d623561de05", size = 503018, upload-time = "2024-12-09T18:10:18.92Z" }, - { url = "https://files.pythonhosted.org/packages/d5/cd/d5a5501d72a11fe3e5fd65c78c884e5164eefe80077680533919be22d3a3/jiter-0.8.2-cp313-cp313-win32.whl", hash = "sha256:789361ed945d8d42850f919342a8665d2dc79e7e44ca1c97cc786966a21f627a", size = 203190, upload-time = "2024-12-09T18:10:20.801Z" }, - { url = "https://files.pythonhosted.org/packages/51/bf/e5ca301245ba951447e3ad677a02a64a8845b185de2603dabd83e1e4b9c6/jiter-0.8.2-cp313-cp313-win_amd64.whl", hash = "sha256:ab7f43235d71e03b941c1630f4b6e3055d46b6cb8728a17663eaac9d8e83a865", size = 203551, upload-time = "2024-12-09T18:10:22.822Z" }, - { url = "https://files.pythonhosted.org/packages/2f/3c/71a491952c37b87d127790dd7a0b1ebea0514c6b6ad30085b16bbe00aee6/jiter-0.8.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b426f72cd77da3fec300ed3bc990895e2dd6b49e3bfe6c438592a3ba660e41ca", size = 308347, upload-time = "2024-12-09T18:10:24.139Z" }, - { url = "https://files.pythonhosted.org/packages/a0/4c/c02408042e6a7605ec063daed138e07b982fdb98467deaaf1c90950cf2c6/jiter-0.8.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2dd880785088ff2ad21ffee205e58a8c1ddabc63612444ae41e5e4b321b39c0", size = 342875, upload-time = "2024-12-09T18:10:25.553Z" }, - { url = "https://files.pythonhosted.org/packages/91/61/c80ef80ed8a0a21158e289ef70dac01e351d929a1c30cb0f49be60772547/jiter-0.8.2-cp313-cp313t-win_amd64.whl", hash = "sha256:3ac9f578c46f22405ff7f8b1f5848fb753cc4b8377fbec8470a7dc3997ca7566", size = 202374, upload-time = "2024-12-09T18:10:26.958Z" }, +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/9d/ae7ddb4b8ab3fb1b51faf4deb36cb48a4fbbd7cb36bad6a5fca4741306f7/jiter-0.10.0.tar.gz", hash = "sha256:07a7142c38aacc85194391108dc91b5b57093c978a9932bd86a36862759d9500", size = 162759, upload-time = "2025-05-18T19:04:59.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/dd/6cefc6bd68b1c3c979cecfa7029ab582b57690a31cd2f346c4d0ce7951b6/jiter-0.10.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3bebe0c558e19902c96e99217e0b8e8b17d570906e72ed8a87170bc290b1e978", size = 317473, upload-time = "2025-05-18T19:03:25.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/cf/fc33f5159ce132be1d8dd57251a1ec7a631c7df4bd11e1cd198308c6ae32/jiter-0.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:558cc7e44fd8e507a236bee6a02fa17199ba752874400a0ca6cd6e2196cdb7dc", size = 321971, upload-time = "2025-05-18T19:03:27.255Z" }, + { url = "https://files.pythonhosted.org/packages/68/a4/da3f150cf1d51f6c472616fb7650429c7ce053e0c962b41b68557fdf6379/jiter-0.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d613e4b379a07d7c8453c5712ce7014e86c6ac93d990a0b8e7377e18505e98d", size = 345574, upload-time = "2025-05-18T19:03:28.63Z" }, + { url = "https://files.pythonhosted.org/packages/84/34/6e8d412e60ff06b186040e77da5f83bc158e9735759fcae65b37d681f28b/jiter-0.10.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f62cf8ba0618eda841b9bf61797f21c5ebd15a7a1e19daab76e4e4b498d515b2", size = 371028, upload-time = "2025-05-18T19:03:30.292Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d9/9ee86173aae4576c35a2f50ae930d2ccb4c4c236f6cb9353267aa1d626b7/jiter-0.10.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:919d139cdfa8ae8945112398511cb7fca58a77382617d279556b344867a37e61", size = 491083, upload-time = "2025-05-18T19:03:31.654Z" }, + { url = "https://files.pythonhosted.org/packages/d9/2c/f955de55e74771493ac9e188b0f731524c6a995dffdcb8c255b89c6fb74b/jiter-0.10.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:13ddbc6ae311175a3b03bd8994881bc4635c923754932918e18da841632349db", size = 388821, upload-time = "2025-05-18T19:03:33.184Z" }, + { url = "https://files.pythonhosted.org/packages/81/5a/0e73541b6edd3f4aada586c24e50626c7815c561a7ba337d6a7eb0a915b4/jiter-0.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c440ea003ad10927a30521a9062ce10b5479592e8a70da27f21eeb457b4a9c5", size = 352174, upload-time = "2025-05-18T19:03:34.965Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c0/61eeec33b8c75b31cae42be14d44f9e6fe3ac15a4e58010256ac3abf3638/jiter-0.10.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dc347c87944983481e138dea467c0551080c86b9d21de6ea9306efb12ca8f606", size = 391869, upload-time = "2025-05-18T19:03:36.436Z" }, + { url = "https://files.pythonhosted.org/packages/41/22/5beb5ee4ad4ef7d86f5ea5b4509f680a20706c4a7659e74344777efb7739/jiter-0.10.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:13252b58c1f4d8c5b63ab103c03d909e8e1e7842d302473f482915d95fefd605", size = 523741, upload-time = "2025-05-18T19:03:38.168Z" }, + { url = "https://files.pythonhosted.org/packages/ea/10/768e8818538e5817c637b0df52e54366ec4cebc3346108a4457ea7a98f32/jiter-0.10.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7d1bbf3c465de4a24ab12fb7766a0003f6f9bce48b8b6a886158c4d569452dc5", size = 514527, upload-time = "2025-05-18T19:03:39.577Z" }, + { url = "https://files.pythonhosted.org/packages/73/6d/29b7c2dc76ce93cbedabfd842fc9096d01a0550c52692dfc33d3cc889815/jiter-0.10.0-cp311-cp311-win32.whl", hash = "sha256:db16e4848b7e826edca4ccdd5b145939758dadf0dc06e7007ad0e9cfb5928ae7", size = 210765, upload-time = "2025-05-18T19:03:41.271Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/d394706deb4c660137caf13e33d05a031d734eb99c051142e039d8ceb794/jiter-0.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:9c9c1d5f10e18909e993f9641f12fe1c77b3e9b533ee94ffa970acc14ded3812", size = 209234, upload-time = "2025-05-18T19:03:42.918Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b5/348b3313c58f5fbfb2194eb4d07e46a35748ba6e5b3b3046143f3040bafa/jiter-0.10.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1e274728e4a5345a6dde2d343c8da018b9d4bd4350f5a472fa91f66fda44911b", size = 312262, upload-time = "2025-05-18T19:03:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/9c/4a/6a2397096162b21645162825f058d1709a02965606e537e3304b02742e9b/jiter-0.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7202ae396446c988cb2a5feb33a543ab2165b786ac97f53b59aafb803fef0744", size = 320124, upload-time = "2025-05-18T19:03:46.341Z" }, + { url = "https://files.pythonhosted.org/packages/2a/85/1ce02cade7516b726dd88f59a4ee46914bf79d1676d1228ef2002ed2f1c9/jiter-0.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23ba7722d6748b6920ed02a8f1726fb4b33e0fd2f3f621816a8b486c66410ab2", size = 345330, upload-time = "2025-05-18T19:03:47.596Z" }, + { url = "https://files.pythonhosted.org/packages/75/d0/bb6b4f209a77190ce10ea8d7e50bf3725fc16d3372d0a9f11985a2b23eff/jiter-0.10.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:371eab43c0a288537d30e1f0b193bc4eca90439fc08a022dd83e5e07500ed026", size = 369670, upload-time = "2025-05-18T19:03:49.334Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f5/a61787da9b8847a601e6827fbc42ecb12be2c925ced3252c8ffcb56afcaf/jiter-0.10.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c675736059020365cebc845a820214765162728b51ab1e03a1b7b3abb70f74c", size = 489057, upload-time = "2025-05-18T19:03:50.66Z" }, + { url = "https://files.pythonhosted.org/packages/12/e4/6f906272810a7b21406c760a53aadbe52e99ee070fc5c0cb191e316de30b/jiter-0.10.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0c5867d40ab716e4684858e4887489685968a47e3ba222e44cde6e4a2154f959", size = 389372, upload-time = "2025-05-18T19:03:51.98Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ba/77013b0b8ba904bf3762f11e0129b8928bff7f978a81838dfcc958ad5728/jiter-0.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395bb9a26111b60141757d874d27fdea01b17e8fac958b91c20128ba8f4acc8a", size = 352038, upload-time = "2025-05-18T19:03:53.703Z" }, + { url = "https://files.pythonhosted.org/packages/67/27/c62568e3ccb03368dbcc44a1ef3a423cb86778a4389e995125d3d1aaa0a4/jiter-0.10.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6842184aed5cdb07e0c7e20e5bdcfafe33515ee1741a6835353bb45fe5d1bd95", size = 391538, upload-time = "2025-05-18T19:03:55.046Z" }, + { url = "https://files.pythonhosted.org/packages/c0/72/0d6b7e31fc17a8fdce76164884edef0698ba556b8eb0af9546ae1a06b91d/jiter-0.10.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:62755d1bcea9876770d4df713d82606c8c1a3dca88ff39046b85a048566d56ea", size = 523557, upload-time = "2025-05-18T19:03:56.386Z" }, + { url = "https://files.pythonhosted.org/packages/2f/09/bc1661fbbcbeb6244bd2904ff3a06f340aa77a2b94e5a7373fd165960ea3/jiter-0.10.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:533efbce2cacec78d5ba73a41756beff8431dfa1694b6346ce7af3a12c42202b", size = 514202, upload-time = "2025-05-18T19:03:57.675Z" }, + { url = "https://files.pythonhosted.org/packages/1b/84/5a5d5400e9d4d54b8004c9673bbe4403928a00d28529ff35b19e9d176b19/jiter-0.10.0-cp312-cp312-win32.whl", hash = "sha256:8be921f0cadd245e981b964dfbcd6fd4bc4e254cdc069490416dd7a2632ecc01", size = 211781, upload-time = "2025-05-18T19:03:59.025Z" }, + { url = "https://files.pythonhosted.org/packages/9b/52/7ec47455e26f2d6e5f2ea4951a0652c06e5b995c291f723973ae9e724a65/jiter-0.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7c7d785ae9dda68c2678532a5a1581347e9c15362ae9f6e68f3fdbfb64f2e49", size = 206176, upload-time = "2025-05-18T19:04:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b0/279597e7a270e8d22623fea6c5d4eeac328e7d95c236ed51a2b884c54f70/jiter-0.10.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e0588107ec8e11b6f5ef0e0d656fb2803ac6cf94a96b2b9fc675c0e3ab5e8644", size = 311617, upload-time = "2025-05-18T19:04:02.078Z" }, + { url = "https://files.pythonhosted.org/packages/91/e3/0916334936f356d605f54cc164af4060e3e7094364add445a3bc79335d46/jiter-0.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cafc4628b616dc32530c20ee53d71589816cf385dd9449633e910d596b1f5c8a", size = 318947, upload-time = "2025-05-18T19:04:03.347Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8e/fd94e8c02d0e94539b7d669a7ebbd2776e51f329bb2c84d4385e8063a2ad/jiter-0.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:520ef6d981172693786a49ff5b09eda72a42e539f14788124a07530f785c3ad6", size = 344618, upload-time = "2025-05-18T19:04:04.709Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b0/f9f0a2ec42c6e9c2e61c327824687f1e2415b767e1089c1d9135f43816bd/jiter-0.10.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:554dedfd05937f8fc45d17ebdf298fe7e0c77458232bcb73d9fbbf4c6455f5b3", size = 368829, upload-time = "2025-05-18T19:04:06.912Z" }, + { url = "https://files.pythonhosted.org/packages/e8/57/5bbcd5331910595ad53b9fd0c610392ac68692176f05ae48d6ce5c852967/jiter-0.10.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5bc299da7789deacf95f64052d97f75c16d4fc8c4c214a22bf8d859a4288a1c2", size = 491034, upload-time = "2025-05-18T19:04:08.222Z" }, + { url = "https://files.pythonhosted.org/packages/9b/be/c393df00e6e6e9e623a73551774449f2f23b6ec6a502a3297aeeece2c65a/jiter-0.10.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5161e201172de298a8a1baad95eb85db4fb90e902353b1f6a41d64ea64644e25", size = 388529, upload-time = "2025-05-18T19:04:09.566Z" }, + { url = "https://files.pythonhosted.org/packages/42/3e/df2235c54d365434c7f150b986a6e35f41ebdc2f95acea3036d99613025d/jiter-0.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e2227db6ba93cb3e2bf67c87e594adde0609f146344e8207e8730364db27041", size = 350671, upload-time = "2025-05-18T19:04:10.98Z" }, + { url = "https://files.pythonhosted.org/packages/c6/77/71b0b24cbcc28f55ab4dbfe029f9a5b73aeadaba677843fc6dc9ed2b1d0a/jiter-0.10.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:15acb267ea5e2c64515574b06a8bf393fbfee6a50eb1673614aa45f4613c0cca", size = 390864, upload-time = "2025-05-18T19:04:12.722Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d3/ef774b6969b9b6178e1d1e7a89a3bd37d241f3d3ec5f8deb37bbd203714a/jiter-0.10.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:901b92f2e2947dc6dfcb52fd624453862e16665ea909a08398dde19c0731b7f4", size = 522989, upload-time = "2025-05-18T19:04:14.261Z" }, + { url = "https://files.pythonhosted.org/packages/0c/41/9becdb1d8dd5d854142f45a9d71949ed7e87a8e312b0bede2de849388cb9/jiter-0.10.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d0cb9a125d5a3ec971a094a845eadde2db0de85b33c9f13eb94a0c63d463879e", size = 513495, upload-time = "2025-05-18T19:04:15.603Z" }, + { url = "https://files.pythonhosted.org/packages/9c/36/3468e5a18238bdedae7c4d19461265b5e9b8e288d3f86cd89d00cbb48686/jiter-0.10.0-cp313-cp313-win32.whl", hash = "sha256:48a403277ad1ee208fb930bdf91745e4d2d6e47253eedc96e2559d1e6527006d", size = 211289, upload-time = "2025-05-18T19:04:17.541Z" }, + { url = "https://files.pythonhosted.org/packages/7e/07/1c96b623128bcb913706e294adb5f768fb7baf8db5e1338ce7b4ee8c78ef/jiter-0.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:75f9eb72ecb640619c29bf714e78c9c46c9c4eaafd644bf78577ede459f330d4", size = 205074, upload-time = "2025-05-18T19:04:19.21Z" }, + { url = "https://files.pythonhosted.org/packages/54/46/caa2c1342655f57d8f0f2519774c6d67132205909c65e9aa8255e1d7b4f4/jiter-0.10.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:28ed2a4c05a1f32ef0e1d24c2611330219fed727dae01789f4a335617634b1ca", size = 318225, upload-time = "2025-05-18T19:04:20.583Z" }, + { url = "https://files.pythonhosted.org/packages/43/84/c7d44c75767e18946219ba2d703a5a32ab37b0bc21886a97bc6062e4da42/jiter-0.10.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14a4c418b1ec86a195f1ca69da8b23e8926c752b685af665ce30777233dfe070", size = 350235, upload-time = "2025-05-18T19:04:22.363Z" }, + { url = "https://files.pythonhosted.org/packages/01/16/f5a0135ccd968b480daad0e6ab34b0c7c5ba3bc447e5088152696140dcb3/jiter-0.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:d7bfed2fe1fe0e4dda6ef682cee888ba444b21e7a6553e03252e4feb6cf0adca", size = 207278, upload-time = "2025-05-18T19:04:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9b/1d646da42c3de6c2188fdaa15bce8ecb22b635904fc68be025e21249ba44/jiter-0.10.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:5e9251a5e83fab8d87799d3e1a46cb4b7f2919b895c6f4483629ed2446f66522", size = 310866, upload-time = "2025-05-18T19:04:24.891Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0e/26538b158e8a7c7987e94e7aeb2999e2e82b1f9d2e1f6e9874ddf71ebda0/jiter-0.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:023aa0204126fe5b87ccbcd75c8a0d0261b9abdbbf46d55e7ae9f8e22424eeb8", size = 318772, upload-time = "2025-05-18T19:04:26.161Z" }, + { url = "https://files.pythonhosted.org/packages/7b/fb/d302893151caa1c2636d6574d213e4b34e31fd077af6050a9c5cbb42f6fb/jiter-0.10.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c189c4f1779c05f75fc17c0c1267594ed918996a231593a21a5ca5438445216", size = 344534, upload-time = "2025-05-18T19:04:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/01/d8/5780b64a149d74e347c5128d82176eb1e3241b1391ac07935693466d6219/jiter-0.10.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:15720084d90d1098ca0229352607cd68256c76991f6b374af96f36920eae13c4", size = 369087, upload-time = "2025-05-18T19:04:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5b/f235a1437445160e777544f3ade57544daf96ba7e96c1a5b24a6f7ac7004/jiter-0.10.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4f2fb68e5f1cfee30e2b2a09549a00683e0fde4c6a2ab88c94072fc33cb7426", size = 490694, upload-time = "2025-05-18T19:04:30.183Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/9c3d4617caa2ff89cf61b41e83820c27ebb3f7b5fae8a72901e8cd6ff9be/jiter-0.10.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce541693355fc6da424c08b7edf39a2895f58d6ea17d92cc2b168d20907dee12", size = 388992, upload-time = "2025-05-18T19:04:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/68/b1/344fd14049ba5c94526540af7eb661871f9c54d5f5601ff41a959b9a0bbd/jiter-0.10.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31c50c40272e189d50006ad5c73883caabb73d4e9748a688b216e85a9a9ca3b9", size = 351723, upload-time = "2025-05-18T19:04:33.467Z" }, + { url = "https://files.pythonhosted.org/packages/41/89/4c0e345041186f82a31aee7b9d4219a910df672b9fef26f129f0cda07a29/jiter-0.10.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fa3402a2ff9815960e0372a47b75c76979d74402448509ccd49a275fa983ef8a", size = 392215, upload-time = "2025-05-18T19:04:34.827Z" }, + { url = "https://files.pythonhosted.org/packages/55/58/ee607863e18d3f895feb802154a2177d7e823a7103f000df182e0f718b38/jiter-0.10.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:1956f934dca32d7bb647ea21d06d93ca40868b505c228556d3373cbd255ce853", size = 522762, upload-time = "2025-05-18T19:04:36.19Z" }, + { url = "https://files.pythonhosted.org/packages/15/d0/9123fb41825490d16929e73c212de9a42913d68324a8ce3c8476cae7ac9d/jiter-0.10.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:fcedb049bdfc555e261d6f65a6abe1d5ad68825b7202ccb9692636c70fcced86", size = 513427, upload-time = "2025-05-18T19:04:37.544Z" }, + { url = "https://files.pythonhosted.org/packages/d8/b3/2bd02071c5a2430d0b70403a34411fc519c2f227da7b03da9ba6a956f931/jiter-0.10.0-cp314-cp314-win32.whl", hash = "sha256:ac509f7eccca54b2a29daeb516fb95b6f0bd0d0d8084efaf8ed5dfc7b9f0b357", size = 210127, upload-time = "2025-05-18T19:04:38.837Z" }, + { url = "https://files.pythonhosted.org/packages/03/0c/5fe86614ea050c3ecd728ab4035534387cd41e7c1855ef6c031f1ca93e3f/jiter-0.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5ed975b83a2b8639356151cef5c0d597c68376fc4922b45d0eb384ac058cfa00", size = 318527, upload-time = "2025-05-18T19:04:40.612Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4a/4175a563579e884192ba6e81725fc0448b042024419be8d83aa8a80a3f44/jiter-0.10.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3aa96f2abba33dc77f79b4cf791840230375f9534e5fac927ccceb58c5e604a5", size = 354213, upload-time = "2025-05-18T19:04:41.894Z" }, ] [[package]] @@ -902,14 +968,14 @@ dev = [{ name = "pyright", specifier = ">=1.1.389" }] [[package]] name = "markdown-it-py" -version = "3.0.0" +version = "4.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" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] [[package]] @@ -971,83 +1037,109 @@ wheels = [ [[package]] name = "msal" -version = "1.31.1" +version = "1.33.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyjwt", extra = ["crypto"] }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3f/f3/cdf2681e83a73c3355883c2884b6ff2f2d2aadfc399c28e9ac4edc3994fd/msal-1.31.1.tar.gz", hash = "sha256:11b5e6a3f802ffd3a72107203e20c4eac6ef53401961b880af2835b723d80578", size = 145362, upload-time = "2024-11-18T09:51:10.143Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/da/81acbe0c1fd7e9e4ec35f55dadeba9833a847b9a6ba2e2d1e4432da901dd/msal-1.33.0.tar.gz", hash = "sha256:836ad80faa3e25a7d71015c990ce61f704a87328b1e73bcbb0623a18cbf17510", size = 153801, upload-time = "2025-07-22T19:36:33.693Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/30/7c/489cd931a752d05753d730e848039f08f65f86237cf1b8724d0a1cbd700b/msal-1.31.1-py3-none-any.whl", hash = "sha256:29d9882de247e96db01386496d59f29035e5e841bcac892e6d7bf4390bf6bd17", size = 113216, upload-time = "2024-11-18T09:51:08.402Z" }, + { url = "https://files.pythonhosted.org/packages/86/5b/fbc73e91f7727ae1e79b21ed833308e99dc11cc1cd3d4717f579775de5e9/msal-1.33.0-py3-none-any.whl", hash = "sha256:c0cd41cecf8eaed733ee7e3be9e040291eba53b0f262d3ae9c58f38b04244273", size = 116853, upload-time = "2025-07-22T19:36:32.403Z" }, ] [[package]] name = "msal-extensions" -version = "1.2.0" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "msal" }, - { name = "portalocker" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2d/38/ad49272d0a5af95f7a0cb64a79bbd75c9c187f3b789385a143d8d537a5eb/msal_extensions-1.2.0.tar.gz", hash = "sha256:6f41b320bfd2933d631a215c91ca0dd3e67d84bd1a2f50ce917d5874ec646bef", size = 22391, upload-time = "2024-06-23T02:15:37.702Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315, upload-time = "2025-03-14T23:51:03.902Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/69/314d887a01599669fb330da14e5c6ff5f138609e322812a942a74ef9b765/msal_extensions-1.2.0-py3-none-any.whl", hash = "sha256:cf5ba83a2113fa6dc011a254a72f1c223c88d7dfad74cc30617c4679a417704d", size = 19254, upload-time = "2024-06-23T02:15:36.584Z" }, + { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" }, ] [[package]] name = "multidict" -version = "6.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/be/504b89a5e9ca731cd47487e91c469064f8ae5af93b7259758dcfc2b9c848/multidict-6.1.0.tar.gz", hash = "sha256:22ae2ebf9b0c69d206c003e2f6a914ea33f0a932d4aa16f236afc049d9958f4a", size = 64002, upload-time = "2024-09-09T23:49:38.163Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/13/df3505a46d0cd08428e4c8169a196131d1b0c4b515c3649829258843dde6/multidict-6.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3efe2c2cb5763f2f1b275ad2bf7a287d3f7ebbef35648a9726e3b69284a4f3d6", size = 48570, upload-time = "2024-09-09T23:47:41.36Z" }, - { url = "https://files.pythonhosted.org/packages/f0/e1/a215908bfae1343cdb72f805366592bdd60487b4232d039c437fe8f5013d/multidict-6.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7053d3b0353a8b9de430a4f4b4268ac9a4fb3481af37dfe49825bf45ca24156", size = 29316, upload-time = "2024-09-09T23:47:42.612Z" }, - { url = "https://files.pythonhosted.org/packages/70/0f/6dc70ddf5d442702ed74f298d69977f904960b82368532c88e854b79f72b/multidict-6.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:27e5fc84ccef8dfaabb09d82b7d179c7cf1a3fbc8a966f8274fcb4ab2eb4cadb", size = 29640, upload-time = "2024-09-09T23:47:44.028Z" }, - { url = "https://files.pythonhosted.org/packages/d8/6d/9c87b73a13d1cdea30b321ef4b3824449866bd7f7127eceed066ccb9b9ff/multidict-6.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e2b90b43e696f25c62656389d32236e049568b39320e2735d51f08fd362761b", size = 131067, upload-time = "2024-09-09T23:47:45.617Z" }, - { url = "https://files.pythonhosted.org/packages/cc/1e/1b34154fef373371fd6c65125b3d42ff5f56c7ccc6bfff91b9b3c60ae9e0/multidict-6.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d83a047959d38a7ff552ff94be767b7fd79b831ad1cd9920662db05fec24fe72", size = 138507, upload-time = "2024-09-09T23:47:47.429Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e0/0bc6b2bac6e461822b5f575eae85da6aae76d0e2a79b6665d6206b8e2e48/multidict-6.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1a9dd711d0877a1ece3d2e4fea11a8e75741ca21954c919406b44e7cf971304", size = 133905, upload-time = "2024-09-09T23:47:48.878Z" }, - { url = "https://files.pythonhosted.org/packages/ba/af/73d13b918071ff9b2205fcf773d316e0f8fefb4ec65354bbcf0b10908cc6/multidict-6.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec2abea24d98246b94913b76a125e855eb5c434f7c46546046372fe60f666351", size = 129004, upload-time = "2024-09-09T23:47:50.124Z" }, - { url = "https://files.pythonhosted.org/packages/74/21/23960627b00ed39643302d81bcda44c9444ebcdc04ee5bedd0757513f259/multidict-6.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4867cafcbc6585e4b678876c489b9273b13e9fff9f6d6d66add5e15d11d926cb", size = 121308, upload-time = "2024-09-09T23:47:51.97Z" }, - { url = "https://files.pythonhosted.org/packages/8b/5c/cf282263ffce4a596ed0bb2aa1a1dddfe1996d6a62d08842a8d4b33dca13/multidict-6.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b48204e8d955c47c55b72779802b219a39acc3ee3d0116d5080c388970b76e3", size = 132608, upload-time = "2024-09-09T23:47:53.201Z" }, - { url = "https://files.pythonhosted.org/packages/d7/3e/97e778c041c72063f42b290888daff008d3ab1427f5b09b714f5a8eff294/multidict-6.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8fff389528cad1618fb4b26b95550327495462cd745d879a8c7c2115248e399", size = 127029, upload-time = "2024-09-09T23:47:54.435Z" }, - { url = "https://files.pythonhosted.org/packages/47/ac/3efb7bfe2f3aefcf8d103e9a7162572f01936155ab2f7ebcc7c255a23212/multidict-6.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a7a9541cd308eed5e30318430a9c74d2132e9a8cb46b901326272d780bf2d423", size = 137594, upload-time = "2024-09-09T23:47:55.659Z" }, - { url = "https://files.pythonhosted.org/packages/42/9b/6c6e9e8dc4f915fc90a9b7798c44a30773dea2995fdcb619870e705afe2b/multidict-6.1.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:da1758c76f50c39a2efd5e9859ce7d776317eb1dd34317c8152ac9251fc574a3", size = 134556, upload-time = "2024-09-09T23:47:56.98Z" }, - { url = "https://files.pythonhosted.org/packages/1d/10/8e881743b26aaf718379a14ac58572a240e8293a1c9d68e1418fb11c0f90/multidict-6.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c943a53e9186688b45b323602298ab727d8865d8c9ee0b17f8d62d14b56f0753", size = 130993, upload-time = "2024-09-09T23:47:58.163Z" }, - { url = "https://files.pythonhosted.org/packages/45/84/3eb91b4b557442802d058a7579e864b329968c8d0ea57d907e7023c677f2/multidict-6.1.0-cp311-cp311-win32.whl", hash = "sha256:90f8717cb649eea3504091e640a1b8568faad18bd4b9fcd692853a04475a4b80", size = 26405, upload-time = "2024-09-09T23:47:59.391Z" }, - { url = "https://files.pythonhosted.org/packages/9f/0b/ad879847ecbf6d27e90a6eabb7eff6b62c129eefe617ea45eae7c1f0aead/multidict-6.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:82176036e65644a6cc5bd619f65f6f19781e8ec2e5330f51aa9ada7504cc1926", size = 28795, upload-time = "2024-09-09T23:48:00.359Z" }, - { url = "https://files.pythonhosted.org/packages/fd/16/92057c74ba3b96d5e211b553895cd6dc7cc4d1e43d9ab8fafc727681ef71/multidict-6.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b04772ed465fa3cc947db808fa306d79b43e896beb677a56fb2347ca1a49c1fa", size = 48713, upload-time = "2024-09-09T23:48:01.893Z" }, - { url = "https://files.pythonhosted.org/packages/94/3d/37d1b8893ae79716179540b89fc6a0ee56b4a65fcc0d63535c6f5d96f217/multidict-6.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6180c0ae073bddeb5a97a38c03f30c233e0a4d39cd86166251617d1bbd0af436", size = 29516, upload-time = "2024-09-09T23:48:03.463Z" }, - { url = "https://files.pythonhosted.org/packages/a2/12/adb6b3200c363062f805275b4c1e656be2b3681aada66c80129932ff0bae/multidict-6.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:071120490b47aa997cca00666923a83f02c7fbb44f71cf7f136df753f7fa8761", size = 29557, upload-time = "2024-09-09T23:48:04.905Z" }, - { url = "https://files.pythonhosted.org/packages/47/e9/604bb05e6e5bce1e6a5cf80a474e0f072e80d8ac105f1b994a53e0b28c42/multidict-6.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50b3a2710631848991d0bf7de077502e8994c804bb805aeb2925a981de58ec2e", size = 130170, upload-time = "2024-09-09T23:48:06.862Z" }, - { url = "https://files.pythonhosted.org/packages/7e/13/9efa50801785eccbf7086b3c83b71a4fb501a4d43549c2f2f80b8787d69f/multidict-6.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b58c621844d55e71c1b7f7c498ce5aa6985d743a1a59034c57a905b3f153c1ef", size = 134836, upload-time = "2024-09-09T23:48:08.537Z" }, - { url = "https://files.pythonhosted.org/packages/bf/0f/93808b765192780d117814a6dfcc2e75de6dcc610009ad408b8814dca3ba/multidict-6.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55b6d90641869892caa9ca42ff913f7ff1c5ece06474fbd32fb2cf6834726c95", size = 133475, upload-time = "2024-09-09T23:48:09.865Z" }, - { url = "https://files.pythonhosted.org/packages/d3/c8/529101d7176fe7dfe1d99604e48d69c5dfdcadb4f06561f465c8ef12b4df/multidict-6.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b820514bfc0b98a30e3d85462084779900347e4d49267f747ff54060cc33925", size = 131049, upload-time = "2024-09-09T23:48:11.115Z" }, - { url = "https://files.pythonhosted.org/packages/ca/0c/fc85b439014d5a58063e19c3a158a889deec399d47b5269a0f3b6a2e28bc/multidict-6.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10a9b09aba0c5b48c53761b7c720aaaf7cf236d5fe394cd399c7ba662d5f9966", size = 120370, upload-time = "2024-09-09T23:48:12.78Z" }, - { url = "https://files.pythonhosted.org/packages/db/46/d4416eb20176492d2258fbd47b4abe729ff3b6e9c829ea4236f93c865089/multidict-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e16bf3e5fc9f44632affb159d30a437bfe286ce9e02754759be5536b169b305", size = 125178, upload-time = "2024-09-09T23:48:14.295Z" }, - { url = "https://files.pythonhosted.org/packages/5b/46/73697ad7ec521df7de5531a32780bbfd908ded0643cbe457f981a701457c/multidict-6.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:76f364861c3bfc98cbbcbd402d83454ed9e01a5224bb3a28bf70002a230f73e2", size = 119567, upload-time = "2024-09-09T23:48:16.284Z" }, - { url = "https://files.pythonhosted.org/packages/cd/ed/51f060e2cb0e7635329fa6ff930aa5cffa17f4c7f5c6c3ddc3500708e2f2/multidict-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:820c661588bd01a0aa62a1283f20d2be4281b086f80dad9e955e690c75fb54a2", size = 129822, upload-time = "2024-09-09T23:48:17.835Z" }, - { url = "https://files.pythonhosted.org/packages/df/9e/ee7d1954b1331da3eddea0c4e08d9142da5f14b1321c7301f5014f49d492/multidict-6.1.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0e5f362e895bc5b9e67fe6e4ded2492d8124bdf817827f33c5b46c2fe3ffaca6", size = 128656, upload-time = "2024-09-09T23:48:19.576Z" }, - { url = "https://files.pythonhosted.org/packages/77/00/8538f11e3356b5d95fa4b024aa566cde7a38aa7a5f08f4912b32a037c5dc/multidict-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ec660d19bbc671e3a6443325f07263be452c453ac9e512f5eb935e7d4ac28b3", size = 125360, upload-time = "2024-09-09T23:48:20.957Z" }, - { url = "https://files.pythonhosted.org/packages/be/05/5d334c1f2462d43fec2363cd00b1c44c93a78c3925d952e9a71caf662e96/multidict-6.1.0-cp312-cp312-win32.whl", hash = "sha256:58130ecf8f7b8112cdb841486404f1282b9c86ccb30d3519faf301b2e5659133", size = 26382, upload-time = "2024-09-09T23:48:22.351Z" }, - { url = "https://files.pythonhosted.org/packages/a3/bf/f332a13486b1ed0496d624bcc7e8357bb8053823e8cd4b9a18edc1d97e73/multidict-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:188215fc0aafb8e03341995e7c4797860181562380f81ed0a87ff455b70bf1f1", size = 28529, upload-time = "2024-09-09T23:48:23.478Z" }, - { url = "https://files.pythonhosted.org/packages/22/67/1c7c0f39fe069aa4e5d794f323be24bf4d33d62d2a348acdb7991f8f30db/multidict-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d569388c381b24671589335a3be6e1d45546c2988c2ebe30fdcada8457a31008", size = 48771, upload-time = "2024-09-09T23:48:24.594Z" }, - { url = "https://files.pythonhosted.org/packages/3c/25/c186ee7b212bdf0df2519eacfb1981a017bda34392c67542c274651daf23/multidict-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:052e10d2d37810b99cc170b785945421141bf7bb7d2f8799d431e7db229c385f", size = 29533, upload-time = "2024-09-09T23:48:26.187Z" }, - { url = "https://files.pythonhosted.org/packages/67/5e/04575fd837e0958e324ca035b339cea174554f6f641d3fb2b4f2e7ff44a2/multidict-6.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f90c822a402cb865e396a504f9fc8173ef34212a342d92e362ca498cad308e28", size = 29595, upload-time = "2024-09-09T23:48:27.305Z" }, - { url = "https://files.pythonhosted.org/packages/d3/b2/e56388f86663810c07cfe4a3c3d87227f3811eeb2d08450b9e5d19d78876/multidict-6.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b225d95519a5bf73860323e633a664b0d85ad3d5bede6d30d95b35d4dfe8805b", size = 130094, upload-time = "2024-09-09T23:48:28.544Z" }, - { url = "https://files.pythonhosted.org/packages/6c/ee/30ae9b4186a644d284543d55d491fbd4239b015d36b23fea43b4c94f7052/multidict-6.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23bfd518810af7de1116313ebd9092cb9aa629beb12f6ed631ad53356ed6b86c", size = 134876, upload-time = "2024-09-09T23:48:30.098Z" }, - { url = "https://files.pythonhosted.org/packages/84/c7/70461c13ba8ce3c779503c70ec9d0345ae84de04521c1f45a04d5f48943d/multidict-6.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c09fcfdccdd0b57867577b719c69e347a436b86cd83747f179dbf0cc0d4c1f3", size = 133500, upload-time = "2024-09-09T23:48:31.793Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9f/002af221253f10f99959561123fae676148dd730e2daa2cd053846a58507/multidict-6.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf6bea52ec97e95560af5ae576bdac3aa3aae0b6758c6efa115236d9e07dae44", size = 131099, upload-time = "2024-09-09T23:48:33.193Z" }, - { url = "https://files.pythonhosted.org/packages/82/42/d1c7a7301d52af79d88548a97e297f9d99c961ad76bbe6f67442bb77f097/multidict-6.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57feec87371dbb3520da6192213c7d6fc892d5589a93db548331954de8248fd2", size = 120403, upload-time = "2024-09-09T23:48:34.942Z" }, - { url = "https://files.pythonhosted.org/packages/68/f3/471985c2c7ac707547553e8f37cff5158030d36bdec4414cb825fbaa5327/multidict-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0c3f390dc53279cbc8ba976e5f8035eab997829066756d811616b652b00a23a3", size = 125348, upload-time = "2024-09-09T23:48:36.222Z" }, - { url = "https://files.pythonhosted.org/packages/67/2c/e6df05c77e0e433c214ec1d21ddd203d9a4770a1f2866a8ca40a545869a0/multidict-6.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:59bfeae4b25ec05b34f1956eaa1cb38032282cd4dfabc5056d0a1ec4d696d3aa", size = 119673, upload-time = "2024-09-09T23:48:37.588Z" }, - { url = "https://files.pythonhosted.org/packages/c5/cd/bc8608fff06239c9fb333f9db7743a1b2eafe98c2666c9a196e867a3a0a4/multidict-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f59caeaf7632cc633b5cf6fc449372b83bbdf0da4ae04d5be36118e46cc0aa", size = 129927, upload-time = "2024-09-09T23:48:39.128Z" }, - { url = "https://files.pythonhosted.org/packages/44/8e/281b69b7bc84fc963a44dc6e0bbcc7150e517b91df368a27834299a526ac/multidict-6.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:37bb93b2178e02b7b618893990941900fd25b6b9ac0fa49931a40aecdf083fe4", size = 128711, upload-time = "2024-09-09T23:48:40.55Z" }, - { url = "https://files.pythonhosted.org/packages/12/a4/63e7cd38ed29dd9f1881d5119f272c898ca92536cdb53ffe0843197f6c85/multidict-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4e9f48f58c2c523d5a06faea47866cd35b32655c46b443f163d08c6d0ddb17d6", size = 125519, upload-time = "2024-09-09T23:48:42.446Z" }, - { url = "https://files.pythonhosted.org/packages/38/e0/4f5855037a72cd8a7a2f60a3952d9aa45feedb37ae7831642102604e8a37/multidict-6.1.0-cp313-cp313-win32.whl", hash = "sha256:3a37ffb35399029b45c6cc33640a92bef403c9fd388acce75cdc88f58bd19a81", size = 26426, upload-time = "2024-09-09T23:48:43.936Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a5/17ee3a4db1e310b7405f5d25834460073a8ccd86198ce044dfaf69eac073/multidict-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:e9aa71e15d9d9beaad2c6b9319edcdc0a49a43ef5c0a4c8265ca9ee7d6c67774", size = 28531, upload-time = "2024-09-09T23:48:45.122Z" }, - { url = "https://files.pythonhosted.org/packages/99/b7/b9e70fde2c0f0c9af4cc5277782a89b66d35948ea3369ec9f598358c3ac5/multidict-6.1.0-py3-none-any.whl", hash = "sha256:48e171e52d1c4d33888e529b999e5900356b9ae588c2f09a52dcefb158b27506", size = 10051, upload-time = "2024-09-09T23:49:36.506Z" }, +version = "6.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/7f/0652e6ed47ab288e3756ea9c0df8b14950781184d4bd7883f4d87dd41245/multidict-6.6.4.tar.gz", hash = "sha256:d2d4e4787672911b48350df02ed3fa3fffdc2f2e8ca06dd6afdf34189b76a9dd", size = 101843, upload-time = "2025-08-11T12:08:48.217Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/7f/90a7f01e2d005d6653c689039977f6856718c75c5579445effb7e60923d1/multidict-6.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c7a0e9b561e6460484318a7612e725df1145d46b0ef57c6b9866441bf6e27e0c", size = 76472, upload-time = "2025-08-11T12:06:29.006Z" }, + { url = "https://files.pythonhosted.org/packages/54/a3/bed07bc9e2bb302ce752f1dabc69e884cd6a676da44fb0e501b246031fdd/multidict-6.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6bf2f10f70acc7a2446965ffbc726e5fc0b272c97a90b485857e5c70022213eb", size = 44634, upload-time = "2025-08-11T12:06:30.374Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4b/ceeb4f8f33cf81277da464307afeaf164fb0297947642585884f5cad4f28/multidict-6.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66247d72ed62d5dd29752ffc1d3b88f135c6a8de8b5f63b7c14e973ef5bda19e", size = 44282, upload-time = "2025-08-11T12:06:31.958Z" }, + { url = "https://files.pythonhosted.org/packages/03/35/436a5da8702b06866189b69f655ffdb8f70796252a8772a77815f1812679/multidict-6.6.4-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:105245cc6b76f51e408451a844a54e6823bbd5a490ebfe5bdfc79798511ceded", size = 229696, upload-time = "2025-08-11T12:06:33.087Z" }, + { url = "https://files.pythonhosted.org/packages/b6/0e/915160be8fecf1fca35f790c08fb74ca684d752fcba62c11daaf3d92c216/multidict-6.6.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cbbc54e58b34c3bae389ef00046be0961f30fef7cb0dd9c7756aee376a4f7683", size = 246665, upload-time = "2025-08-11T12:06:34.448Z" }, + { url = "https://files.pythonhosted.org/packages/08/ee/2f464330acd83f77dcc346f0b1a0eaae10230291450887f96b204b8ac4d3/multidict-6.6.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:56c6b3652f945c9bc3ac6c8178cd93132b8d82dd581fcbc3a00676c51302bc1a", size = 225485, upload-time = "2025-08-11T12:06:35.672Z" }, + { url = "https://files.pythonhosted.org/packages/71/cc/9a117f828b4d7fbaec6adeed2204f211e9caf0a012692a1ee32169f846ae/multidict-6.6.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b95494daf857602eccf4c18ca33337dd2be705bccdb6dddbfc9d513e6addb9d9", size = 257318, upload-time = "2025-08-11T12:06:36.98Z" }, + { url = "https://files.pythonhosted.org/packages/25/77/62752d3dbd70e27fdd68e86626c1ae6bccfebe2bb1f84ae226363e112f5a/multidict-6.6.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e5b1413361cef15340ab9dc61523e653d25723e82d488ef7d60a12878227ed50", size = 254689, upload-time = "2025-08-11T12:06:38.233Z" }, + { url = "https://files.pythonhosted.org/packages/00/6e/fac58b1072a6fc59af5e7acb245e8754d3e1f97f4f808a6559951f72a0d4/multidict-6.6.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e167bf899c3d724f9662ef00b4f7fef87a19c22b2fead198a6f68b263618df52", size = 246709, upload-time = "2025-08-11T12:06:39.517Z" }, + { url = "https://files.pythonhosted.org/packages/01/ef/4698d6842ef5e797c6db7744b0081e36fb5de3d00002cc4c58071097fac3/multidict-6.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:aaea28ba20a9026dfa77f4b80369e51cb767c61e33a2d4043399c67bd95fb7c6", size = 243185, upload-time = "2025-08-11T12:06:40.796Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c9/d82e95ae1d6e4ef396934e9b0e942dfc428775f9554acf04393cce66b157/multidict-6.6.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8c91cdb30809a96d9ecf442ec9bc45e8cfaa0f7f8bdf534e082c2443a196727e", size = 237838, upload-time = "2025-08-11T12:06:42.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/cf/f94af5c36baaa75d44fab9f02e2a6bcfa0cd90acb44d4976a80960759dbc/multidict-6.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1a0ccbfe93ca114c5d65a2471d52d8829e56d467c97b0e341cf5ee45410033b3", size = 246368, upload-time = "2025-08-11T12:06:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/4a/fe/29f23460c3d995f6a4b678cb2e9730e7277231b981f0b234702f0177818a/multidict-6.6.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:55624b3f321d84c403cb7d8e6e982f41ae233d85f85db54ba6286f7295dc8a9c", size = 253339, upload-time = "2025-08-11T12:06:45.597Z" }, + { url = "https://files.pythonhosted.org/packages/29/b6/fd59449204426187b82bf8a75f629310f68c6adc9559dc922d5abe34797b/multidict-6.6.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4a1fb393a2c9d202cb766c76208bd7945bc194eba8ac920ce98c6e458f0b524b", size = 246933, upload-time = "2025-08-11T12:06:46.841Z" }, + { url = "https://files.pythonhosted.org/packages/19/52/d5d6b344f176a5ac3606f7a61fb44dc746e04550e1a13834dff722b8d7d6/multidict-6.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:43868297a5759a845fa3a483fb4392973a95fb1de891605a3728130c52b8f40f", size = 242225, upload-time = "2025-08-11T12:06:48.588Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d3/5b2281ed89ff4d5318d82478a2a2450fcdfc3300da48ff15c1778280ad26/multidict-6.6.4-cp311-cp311-win32.whl", hash = "sha256:ed3b94c5e362a8a84d69642dbeac615452e8af9b8eb825b7bc9f31a53a1051e2", size = 41306, upload-time = "2025-08-11T12:06:49.95Z" }, + { url = "https://files.pythonhosted.org/packages/74/7d/36b045c23a1ab98507aefd44fd8b264ee1dd5e5010543c6fccf82141ccef/multidict-6.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:d8c112f7a90d8ca5d20213aa41eac690bb50a76da153e3afb3886418e61cb22e", size = 46029, upload-time = "2025-08-11T12:06:51.082Z" }, + { url = "https://files.pythonhosted.org/packages/0f/5e/553d67d24432c5cd52b49047f2d248821843743ee6d29a704594f656d182/multidict-6.6.4-cp311-cp311-win_arm64.whl", hash = "sha256:3bb0eae408fa1996d87247ca0d6a57b7fc1dcf83e8a5c47ab82c558c250d4adf", size = 43017, upload-time = "2025-08-11T12:06:52.243Z" }, + { url = "https://files.pythonhosted.org/packages/05/f6/512ffd8fd8b37fb2680e5ac35d788f1d71bbaf37789d21a820bdc441e565/multidict-6.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0ffb87be160942d56d7b87b0fdf098e81ed565add09eaa1294268c7f3caac4c8", size = 76516, upload-time = "2025-08-11T12:06:53.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/58/45c3e75deb8855c36bd66cc1658007589662ba584dbf423d01df478dd1c5/multidict-6.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d191de6cbab2aff5de6c5723101705fd044b3e4c7cfd587a1929b5028b9714b3", size = 45394, upload-time = "2025-08-11T12:06:54.555Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/e8c4472a93a26e4507c0b8e1f0762c0d8a32de1328ef72fd704ef9cc5447/multidict-6.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:38a0956dd92d918ad5feff3db8fcb4a5eb7dba114da917e1a88475619781b57b", size = 43591, upload-time = "2025-08-11T12:06:55.672Z" }, + { url = "https://files.pythonhosted.org/packages/05/51/edf414f4df058574a7265034d04c935aa84a89e79ce90fcf4df211f47b16/multidict-6.6.4-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6865f6d3b7900ae020b495d599fcf3765653bc927951c1abb959017f81ae8287", size = 237215, upload-time = "2025-08-11T12:06:57.213Z" }, + { url = "https://files.pythonhosted.org/packages/c8/45/8b3d6dbad8cf3252553cc41abea09ad527b33ce47a5e199072620b296902/multidict-6.6.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a2088c126b6f72db6c9212ad827d0ba088c01d951cee25e758c450da732c138", size = 258299, upload-time = "2025-08-11T12:06:58.946Z" }, + { url = "https://files.pythonhosted.org/packages/3c/e8/8ca2e9a9f5a435fc6db40438a55730a4bf4956b554e487fa1b9ae920f825/multidict-6.6.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0f37bed7319b848097085d7d48116f545985db988e2256b2e6f00563a3416ee6", size = 242357, upload-time = "2025-08-11T12:07:00.301Z" }, + { url = "https://files.pythonhosted.org/packages/0f/84/80c77c99df05a75c28490b2af8f7cba2a12621186e0a8b0865d8e745c104/multidict-6.6.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:01368e3c94032ba6ca0b78e7ccb099643466cf24f8dc8eefcfdc0571d56e58f9", size = 268369, upload-time = "2025-08-11T12:07:01.638Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e9/920bfa46c27b05fb3e1ad85121fd49f441492dca2449c5bcfe42e4565d8a/multidict-6.6.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe323540c255db0bffee79ad7f048c909f2ab0edb87a597e1c17da6a54e493c", size = 269341, upload-time = "2025-08-11T12:07:02.943Z" }, + { url = "https://files.pythonhosted.org/packages/af/65/753a2d8b05daf496f4a9c367fe844e90a1b2cac78e2be2c844200d10cc4c/multidict-6.6.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8eb3025f17b0a4c3cd08cda49acf312a19ad6e8a4edd9dbd591e6506d999402", size = 256100, upload-time = "2025-08-11T12:07:04.564Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/655be13ae324212bf0bc15d665a4e34844f34c206f78801be42f7a0a8aaa/multidict-6.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bbc14f0365534d35a06970d6a83478b249752e922d662dc24d489af1aa0d1be7", size = 253584, upload-time = "2025-08-11T12:07:05.914Z" }, + { url = "https://files.pythonhosted.org/packages/5c/74/ab2039ecc05264b5cec73eb018ce417af3ebb384ae9c0e9ed42cb33f8151/multidict-6.6.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:75aa52fba2d96bf972e85451b99d8e19cc37ce26fd016f6d4aa60da9ab2b005f", size = 251018, upload-time = "2025-08-11T12:07:08.301Z" }, + { url = "https://files.pythonhosted.org/packages/af/0a/ccbb244ac848e56c6427f2392741c06302bbfba49c0042f1eb3c5b606497/multidict-6.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fefd4a815e362d4f011919d97d7b4a1e566f1dde83dc4ad8cfb5b41de1df68d", size = 251477, upload-time = "2025-08-11T12:07:10.248Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b0/0ed49bba775b135937f52fe13922bc64a7eaf0a3ead84a36e8e4e446e096/multidict-6.6.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:db9801fe021f59a5b375ab778973127ca0ac52429a26e2fd86aa9508f4d26eb7", size = 263575, upload-time = "2025-08-11T12:07:11.928Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/7fb85a85e14de2e44dfb6a24f03c41e2af8697a6df83daddb0e9b7569f73/multidict-6.6.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a650629970fa21ac1fb06ba25dabfc5b8a2054fcbf6ae97c758aa956b8dba802", size = 259649, upload-time = "2025-08-11T12:07:13.244Z" }, + { url = "https://files.pythonhosted.org/packages/03/9e/b3a459bcf9b6e74fa461a5222a10ff9b544cb1cd52fd482fb1b75ecda2a2/multidict-6.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:452ff5da78d4720d7516a3a2abd804957532dd69296cb77319c193e3ffb87e24", size = 251505, upload-time = "2025-08-11T12:07:14.57Z" }, + { url = "https://files.pythonhosted.org/packages/86/a2/8022f78f041dfe6d71e364001a5cf987c30edfc83c8a5fb7a3f0974cff39/multidict-6.6.4-cp312-cp312-win32.whl", hash = "sha256:8c2fcb12136530ed19572bbba61b407f655e3953ba669b96a35036a11a485793", size = 41888, upload-time = "2025-08-11T12:07:15.904Z" }, + { url = "https://files.pythonhosted.org/packages/c7/eb/d88b1780d43a56db2cba24289fa744a9d216c1a8546a0dc3956563fd53ea/multidict-6.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:047d9425860a8c9544fed1b9584f0c8bcd31bcde9568b047c5e567a1025ecd6e", size = 46072, upload-time = "2025-08-11T12:07:17.045Z" }, + { url = "https://files.pythonhosted.org/packages/9f/16/b929320bf5750e2d9d4931835a4c638a19d2494a5b519caaaa7492ebe105/multidict-6.6.4-cp312-cp312-win_arm64.whl", hash = "sha256:14754eb72feaa1e8ae528468f24250dd997b8e2188c3d2f593f9eba259e4b364", size = 43222, upload-time = "2025-08-11T12:07:18.328Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5d/e1db626f64f60008320aab00fbe4f23fc3300d75892a3381275b3d284580/multidict-6.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f46a6e8597f9bd71b31cc708195d42b634c8527fecbcf93febf1052cacc1f16e", size = 75848, upload-time = "2025-08-11T12:07:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/4c/aa/8b6f548d839b6c13887253af4e29c939af22a18591bfb5d0ee6f1931dae8/multidict-6.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:22e38b2bc176c5eb9c0a0e379f9d188ae4cd8b28c0f53b52bce7ab0a9e534657", size = 45060, upload-time = "2025-08-11T12:07:21.163Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c6/f5e97e5d99a729bc2aa58eb3ebfa9f1e56a9b517cc38c60537c81834a73f/multidict-6.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5df8afd26f162da59e218ac0eefaa01b01b2e6cd606cffa46608f699539246da", size = 43269, upload-time = "2025-08-11T12:07:22.392Z" }, + { url = "https://files.pythonhosted.org/packages/dc/31/d54eb0c62516776f36fe67f84a732f97e0b0e12f98d5685bebcc6d396910/multidict-6.6.4-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:49517449b58d043023720aa58e62b2f74ce9b28f740a0b5d33971149553d72aa", size = 237158, upload-time = "2025-08-11T12:07:23.636Z" }, + { url = "https://files.pythonhosted.org/packages/c4/1c/8a10c1c25b23156e63b12165a929d8eb49a6ed769fdbefb06e6f07c1e50d/multidict-6.6.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9408439537c5afdca05edd128a63f56a62680f4b3c234301055d7a2000220f", size = 257076, upload-time = "2025-08-11T12:07:25.049Z" }, + { url = "https://files.pythonhosted.org/packages/ad/86/90e20b5771d6805a119e483fd3d1e8393e745a11511aebca41f0da38c3e2/multidict-6.6.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:87a32d20759dc52a9e850fe1061b6e41ab28e2998d44168a8a341b99ded1dba0", size = 240694, upload-time = "2025-08-11T12:07:26.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/49/484d3e6b535bc0555b52a0a26ba86e4d8d03fd5587d4936dc59ba7583221/multidict-6.6.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52e3c8d43cdfff587ceedce9deb25e6ae77daba560b626e97a56ddcad3756879", size = 266350, upload-time = "2025-08-11T12:07:27.94Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b4/aa4c5c379b11895083d50021e229e90c408d7d875471cb3abf721e4670d6/multidict-6.6.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ad8850921d3a8d8ff6fbef790e773cecfc260bbfa0566998980d3fa8f520bc4a", size = 267250, upload-time = "2025-08-11T12:07:29.303Z" }, + { url = "https://files.pythonhosted.org/packages/80/e5/5e22c5bf96a64bdd43518b1834c6d95a4922cc2066b7d8e467dae9b6cee6/multidict-6.6.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:497a2954adc25c08daff36f795077f63ad33e13f19bfff7736e72c785391534f", size = 254900, upload-time = "2025-08-11T12:07:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/17/38/58b27fed927c07035abc02befacab42491e7388ca105e087e6e0215ead64/multidict-6.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:024ce601f92d780ca1617ad4be5ac15b501cc2414970ffa2bb2bbc2bd5a68fa5", size = 252355, upload-time = "2025-08-11T12:07:32.205Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a1/dad75d23a90c29c02b5d6f3d7c10ab36c3197613be5d07ec49c7791e186c/multidict-6.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a693fc5ed9bdd1c9e898013e0da4dcc640de7963a371c0bd458e50e046bf6438", size = 250061, upload-time = "2025-08-11T12:07:33.623Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1a/ac2216b61c7f116edab6dc3378cca6c70dc019c9a457ff0d754067c58b20/multidict-6.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:190766dac95aab54cae5b152a56520fd99298f32a1266d66d27fdd1b5ac00f4e", size = 249675, upload-time = "2025-08-11T12:07:34.958Z" }, + { url = "https://files.pythonhosted.org/packages/d4/79/1916af833b800d13883e452e8e0977c065c4ee3ab7a26941fbfdebc11895/multidict-6.6.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:34d8f2a5ffdceab9dcd97c7a016deb2308531d5f0fced2bb0c9e1df45b3363d7", size = 261247, upload-time = "2025-08-11T12:07:36.588Z" }, + { url = "https://files.pythonhosted.org/packages/c5/65/d1f84fe08ac44a5fc7391cbc20a7cedc433ea616b266284413fd86062f8c/multidict-6.6.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:59e8d40ab1f5a8597abcef00d04845155a5693b5da00d2c93dbe88f2050f2812", size = 257960, upload-time = "2025-08-11T12:07:39.735Z" }, + { url = "https://files.pythonhosted.org/packages/13/b5/29ec78057d377b195ac2c5248c773703a6b602e132a763e20ec0457e7440/multidict-6.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:467fe64138cfac771f0e949b938c2e1ada2b5af22f39692aa9258715e9ea613a", size = 250078, upload-time = "2025-08-11T12:07:41.525Z" }, + { url = "https://files.pythonhosted.org/packages/c4/0e/7e79d38f70a872cae32e29b0d77024bef7834b0afb406ddae6558d9e2414/multidict-6.6.4-cp313-cp313-win32.whl", hash = "sha256:14616a30fe6d0a48d0a48d1a633ab3b8bec4cf293aac65f32ed116f620adfd69", size = 41708, upload-time = "2025-08-11T12:07:43.405Z" }, + { url = "https://files.pythonhosted.org/packages/9d/34/746696dffff742e97cd6a23da953e55d0ea51fa601fa2ff387b3edcfaa2c/multidict-6.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:40cd05eaeb39e2bc8939451f033e57feaa2ac99e07dbca8afe2be450a4a3b6cf", size = 45912, upload-time = "2025-08-11T12:07:45.082Z" }, + { url = "https://files.pythonhosted.org/packages/c7/87/3bac136181e271e29170d8d71929cdeddeb77f3e8b6a0c08da3a8e9da114/multidict-6.6.4-cp313-cp313-win_arm64.whl", hash = "sha256:f6eb37d511bfae9e13e82cb4d1af36b91150466f24d9b2b8a9785816deb16605", size = 43076, upload-time = "2025-08-11T12:07:46.746Z" }, + { url = "https://files.pythonhosted.org/packages/64/94/0a8e63e36c049b571c9ae41ee301ada29c3fee9643d9c2548d7d558a1d99/multidict-6.6.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:6c84378acd4f37d1b507dfa0d459b449e2321b3ba5f2338f9b085cf7a7ba95eb", size = 82812, upload-time = "2025-08-11T12:07:48.402Z" }, + { url = "https://files.pythonhosted.org/packages/25/1a/be8e369dfcd260d2070a67e65dd3990dd635cbd735b98da31e00ea84cd4e/multidict-6.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0e0558693063c75f3d952abf645c78f3c5dfdd825a41d8c4d8156fc0b0da6e7e", size = 48313, upload-time = "2025-08-11T12:07:49.679Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/dd4ade298674b2f9a7b06a32c94ffbc0497354df8285f27317c66433ce3b/multidict-6.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3f8e2384cb83ebd23fd07e9eada8ba64afc4c759cd94817433ab8c81ee4b403f", size = 46777, upload-time = "2025-08-11T12:07:51.318Z" }, + { url = "https://files.pythonhosted.org/packages/89/db/98aa28bc7e071bfba611ac2ae803c24e96dd3a452b4118c587d3d872c64c/multidict-6.6.4-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f996b87b420995a9174b2a7c1a8daf7db4750be6848b03eb5e639674f7963773", size = 229321, upload-time = "2025-08-11T12:07:52.965Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bc/01ddda2a73dd9d167bd85d0e8ef4293836a8f82b786c63fb1a429bc3e678/multidict-6.6.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc356250cffd6e78416cf5b40dc6a74f1edf3be8e834cf8862d9ed5265cf9b0e", size = 249954, upload-time = "2025-08-11T12:07:54.423Z" }, + { url = "https://files.pythonhosted.org/packages/06/78/6b7c0f020f9aa0acf66d0ab4eb9f08375bac9a50ff5e3edb1c4ccd59eafc/multidict-6.6.4-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dadf95aa862714ea468a49ad1e09fe00fcc9ec67d122f6596a8d40caf6cec7d0", size = 228612, upload-time = "2025-08-11T12:07:55.914Z" }, + { url = "https://files.pythonhosted.org/packages/00/44/3faa416f89b2d5d76e9d447296a81521e1c832ad6e40b92f990697b43192/multidict-6.6.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dd57515bebffd8ebd714d101d4c434063322e4fe24042e90ced41f18b6d3395", size = 257528, upload-time = "2025-08-11T12:07:57.371Z" }, + { url = "https://files.pythonhosted.org/packages/05/5f/77c03b89af0fcb16f018f668207768191fb9dcfb5e3361a5e706a11db2c9/multidict-6.6.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:967af5f238ebc2eb1da4e77af5492219fbd9b4b812347da39a7b5f5c72c0fa45", size = 256329, upload-time = "2025-08-11T12:07:58.844Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e9/ed750a2a9afb4f8dc6f13dc5b67b514832101b95714f1211cd42e0aafc26/multidict-6.6.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a4c6875c37aae9794308ec43e3530e4aa0d36579ce38d89979bbf89582002bb", size = 247928, upload-time = "2025-08-11T12:08:01.037Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b5/e0571bc13cda277db7e6e8a532791d4403dacc9850006cb66d2556e649c0/multidict-6.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f683a551e92bdb7fac545b9c6f9fa2aebdeefa61d607510b3533286fcab67f5", size = 245228, upload-time = "2025-08-11T12:08:02.96Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a3/69a84b0eccb9824491f06368f5b86e72e4af54c3067c37c39099b6687109/multidict-6.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:3ba5aaf600edaf2a868a391779f7a85d93bed147854925f34edd24cc70a3e141", size = 235869, upload-time = "2025-08-11T12:08:04.746Z" }, + { url = "https://files.pythonhosted.org/packages/a9/9d/28802e8f9121a6a0804fa009debf4e753d0a59969ea9f70be5f5fdfcb18f/multidict-6.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:580b643b7fd2c295d83cad90d78419081f53fd532d1f1eb67ceb7060f61cff0d", size = 243446, upload-time = "2025-08-11T12:08:06.332Z" }, + { url = "https://files.pythonhosted.org/packages/38/ea/6c98add069b4878c1d66428a5f5149ddb6d32b1f9836a826ac764b9940be/multidict-6.6.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:37b7187197da6af3ee0b044dbc9625afd0c885f2800815b228a0e70f9a7f473d", size = 252299, upload-time = "2025-08-11T12:08:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/3a/09/8fe02d204473e14c0af3affd50af9078839dfca1742f025cca765435d6b4/multidict-6.6.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e1b93790ed0bc26feb72e2f08299691ceb6da5e9e14a0d13cc74f1869af327a0", size = 246926, upload-time = "2025-08-11T12:08:09.467Z" }, + { url = "https://files.pythonhosted.org/packages/37/3d/7b1e10d774a6df5175ecd3c92bff069e77bed9ec2a927fdd4ff5fe182f67/multidict-6.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a506a77ddee1efcca81ecbeae27ade3e09cdf21a8ae854d766c2bb4f14053f92", size = 243383, upload-time = "2025-08-11T12:08:10.981Z" }, + { url = "https://files.pythonhosted.org/packages/50/b0/a6fae46071b645ae98786ab738447de1ef53742eaad949f27e960864bb49/multidict-6.6.4-cp313-cp313t-win32.whl", hash = "sha256:f93b2b2279883d1d0a9e1bd01f312d6fc315c5e4c1f09e112e4736e2f650bc4e", size = 47775, upload-time = "2025-08-11T12:08:12.439Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0a/2436550b1520091af0600dff547913cb2d66fbac27a8c33bc1b1bccd8d98/multidict-6.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:6d46a180acdf6e87cc41dc15d8f5c2986e1e8739dc25dbb7dac826731ef381a4", size = 53100, upload-time = "2025-08-11T12:08:13.823Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/43ac51faff934086db9c072a94d327d71b7d8b40cd5dcb47311330929ef0/multidict-6.6.4-cp313-cp313t-win_arm64.whl", hash = "sha256:756989334015e3335d087a27331659820d53ba432befdef6a718398b0a8493ad", size = 45501, upload-time = "2025-08-11T12:08:15.173Z" }, + { url = "https://files.pythonhosted.org/packages/fd/69/b547032297c7e63ba2af494edba695d781af8a0c6e89e4d06cf848b21d80/multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c", size = 12313, upload-time = "2025-08-11T12:08:46.891Z" }, ] [[package]] @@ -1061,7 +1153,7 @@ wheels = [ [[package]] name = "openai" -version = "1.89.0" +version = "1.101.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1073,9 +1165,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e5/50/3f71aa0fd00b12ed7e25295912a7b3ed4f77fbf814dcf553159aeb349546/openai-1.89.0.tar.gz", hash = "sha256:c9ae3e85a4dad280176a63f4afd54dc78deadd323bf2d275edc6b2eca6858971", size = 471871, upload-time = "2025-06-20T18:45:04.101Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/7c/eaf06b62281f5ca4f774c4cff066e6ddfd6a027e0ac791be16acec3a95e3/openai-1.101.0.tar.gz", hash = "sha256:29f56df2236069686e64aca0e13c24a4ec310545afb25ef7da2ab1a18523f22d", size = 518415, upload-time = "2025-08-21T21:11:01.645Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/a1/cfb21bd74d40565c61f4cb77d3beede56ba6adfe5f799c797606d1584576/openai-1.89.0-py3-none-any.whl", hash = "sha256:3fe395b3859c45336022026bbedf8e40a35e77c4ee5878ba5eaa6c8e2ff851f6", size = 734682, upload-time = "2025-06-20T18:45:02.402Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a6/0e39baa335bbd1c66c7e0a41dbbec10c5a15ab95c1344e7f7beb28eee65a/openai-1.101.0-py3-none-any.whl", hash = "sha256:6539a446cce154f8d9fb42757acdfd3ed9357ab0d34fcac11096c461da87133b", size = 810772, upload-time = "2025-08-21T21:10:59.215Z" }, ] [[package]] @@ -1117,165 +1209,204 @@ dev = [ [[package]] name = "packaging" -version = "24.2" +version = "25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950, upload-time = "2024-11-08T09:47:47.202Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451, upload-time = "2024-11-08T09:47:44.722Z" }, + { 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 = "pdfminer-six" -version = "20250327" +version = "20250506" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "charset-normalizer" }, { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/08/e9/4688ff2dd985f21380b9c8cd2fa8004bc0f2691f2c301082d767caea7136/pdfminer_six-20250327.tar.gz", hash = "sha256:57f6c34c2702df04cfa3191622a3db0a922ced686d35283232b00094f8914aa1", size = 7381506, upload-time = "2025-03-27T07:51:57.78Z" } +sdist = { url = "https://files.pythonhosted.org/packages/78/46/5223d613ac4963e1f7c07b2660fe0e9e770102ec6bda8c038400113fb215/pdfminer_six-20250506.tar.gz", hash = "sha256:b03cc8df09cf3c7aba8246deae52e0bca7ebb112a38895b5e1d4f5dd2b8ca2e7", size = 7387678, upload-time = "2025-05-06T16:17:00.787Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/2f/409e174b5a0195aa6a814c7359a1285f1c887a4c84aff17ed03f607c06ba/pdfminer_six-20250327-py3-none-any.whl", hash = "sha256:5af494c85b1ecb7c28df5e3a26bb5234a8226a307503d9a09f4958bc154b16a9", size = 5617445, upload-time = "2025-03-27T07:51:55.502Z" }, + { url = "https://files.pythonhosted.org/packages/73/16/7a432c0101fa87457e75cb12c879e1749c5870a786525e2e0f42871d6462/pdfminer_six-20250506-py3-none-any.whl", hash = "sha256:d81ad173f62e5f841b53a8ba63af1a4a355933cfc0ffabd608e568b9193909e3", size = 5620187, upload-time = "2025-05-06T16:16:58.669Z" }, ] [[package]] name = "pdfplumber" -version = "0.11.6" +version = "0.11.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pdfminer-six" }, { name = "pillow" }, { name = "pypdfium2" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ce/37/dca4c8290c252f530e52e758f58e211bb047b34e15d52703355a357524f4/pdfplumber-0.11.6.tar.gz", hash = "sha256:d0f419e031641d9eac70dc18c60e1fc3ca2ec28cce7e149644923c030a0003ff", size = 115611, upload-time = "2025-03-28T03:19:02.353Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/0d/4135821aa7b1a0b77a29fac881ef0890b46b0b002290d04915ed7acc0043/pdfplumber-0.11.7.tar.gz", hash = "sha256:fa67773e5e599de1624255e9b75d1409297c5e1d7493b386ce63648637c67368", size = 115518, upload-time = "2025-06-12T11:30:49.864Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/c4/d2e09fbc937d1f76baae34e526662cc718e23a904321bf4a40282d190033/pdfplumber-0.11.6-py3-none-any.whl", hash = "sha256:169fc2b8dbf328c81a4e9bab30af0c304ad4b472fd7816616eabdb79dc5d9d17", size = 60233, upload-time = "2025-03-28T03:19:00.929Z" }, + { url = "https://files.pythonhosted.org/packages/db/e0/52b67d4f00e09e497aec4f71bc44d395605e8ebcea52543242ed34c25ef9/pdfplumber-0.11.7-py3-none-any.whl", hash = "sha256:edd2195cca68bd770da479cf528a737e362968ec2351e62a6c0b71ff612ac25e", size = 60029, upload-time = "2025-06-12T11:30:48.89Z" }, ] [[package]] name = "pillow" -version = "11.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/af/c097e544e7bd278333db77933e535098c259609c4eb3b85381109602fb5b/pillow-11.1.0.tar.gz", hash = "sha256:368da70808b36d73b4b390a8ffac11069f8a5c85f29eff1f1b01bcf3ef5b2a20", size = 46742715, upload-time = "2025-01-02T08:13:58.407Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/d6/2000bfd8d5414fb70cbbe52c8332f2283ff30ed66a9cde42716c8ecbe22c/pillow-11.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e06695e0326d05b06833b40b7ef477e475d0b1ba3a6d27da1bb48c23209bf457", size = 3229968, upload-time = "2025-01-02T08:10:48.172Z" }, - { url = "https://files.pythonhosted.org/packages/d9/45/3fe487010dd9ce0a06adf9b8ff4f273cc0a44536e234b0fad3532a42c15b/pillow-11.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96f82000e12f23e4f29346e42702b6ed9a2f2fea34a740dd5ffffcc8c539eb35", size = 3101806, upload-time = "2025-01-02T08:10:50.981Z" }, - { url = "https://files.pythonhosted.org/packages/e3/72/776b3629c47d9d5f1c160113158a7a7ad177688d3a1159cd3b62ded5a33a/pillow-11.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3cd561ded2cf2bbae44d4605837221b987c216cff94f49dfeed63488bb228d2", size = 4322283, upload-time = "2025-01-02T08:10:54.724Z" }, - { url = "https://files.pythonhosted.org/packages/e4/c2/e25199e7e4e71d64eeb869f5b72c7ddec70e0a87926398785ab944d92375/pillow-11.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f189805c8be5ca5add39e6f899e6ce2ed824e65fb45f3c28cb2841911da19070", size = 4402945, upload-time = "2025-01-02T08:10:57.376Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ed/51d6136c9d5911f78632b1b86c45241c712c5a80ed7fa7f9120a5dff1eba/pillow-11.1.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:dd0052e9db3474df30433f83a71b9b23bd9e4ef1de13d92df21a52c0303b8ab6", size = 4361228, upload-time = "2025-01-02T08:11:02.374Z" }, - { url = "https://files.pythonhosted.org/packages/48/a4/fbfe9d5581d7b111b28f1d8c2762dee92e9821bb209af9fa83c940e507a0/pillow-11.1.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:837060a8599b8f5d402e97197d4924f05a2e0d68756998345c829c33186217b1", size = 4484021, upload-time = "2025-01-02T08:11:04.431Z" }, - { url = "https://files.pythonhosted.org/packages/39/db/0b3c1a5018117f3c1d4df671fb8e47d08937f27519e8614bbe86153b65a5/pillow-11.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:aa8dd43daa836b9a8128dbe7d923423e5ad86f50a7a14dc688194b7be5c0dea2", size = 4287449, upload-time = "2025-01-02T08:11:07.412Z" }, - { url = "https://files.pythonhosted.org/packages/d9/58/bc128da7fea8c89fc85e09f773c4901e95b5936000e6f303222490c052f3/pillow-11.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0a2f91f8a8b367e7a57c6e91cd25af510168091fb89ec5146003e424e1558a96", size = 4419972, upload-time = "2025-01-02T08:11:09.508Z" }, - { url = "https://files.pythonhosted.org/packages/5f/bb/58f34379bde9fe197f51841c5bbe8830c28bbb6d3801f16a83b8f2ad37df/pillow-11.1.0-cp311-cp311-win32.whl", hash = "sha256:c12fc111ef090845de2bb15009372175d76ac99969bdf31e2ce9b42e4b8cd88f", size = 2291201, upload-time = "2025-01-02T08:11:13.056Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c6/fce9255272bcf0c39e15abd2f8fd8429a954cf344469eaceb9d0d1366913/pillow-11.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fbd43429d0d7ed6533b25fc993861b8fd512c42d04514a0dd6337fb3ccf22761", size = 2625686, upload-time = "2025-01-02T08:11:16.547Z" }, - { url = "https://files.pythonhosted.org/packages/c8/52/8ba066d569d932365509054859f74f2a9abee273edcef5cd75e4bc3e831e/pillow-11.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:f7955ecf5609dee9442cbface754f2c6e541d9e6eda87fad7f7a989b0bdb9d71", size = 2375194, upload-time = "2025-01-02T08:11:19.897Z" }, - { url = "https://files.pythonhosted.org/packages/95/20/9ce6ed62c91c073fcaa23d216e68289e19d95fb8188b9fb7a63d36771db8/pillow-11.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2062ffb1d36544d42fcaa277b069c88b01bb7298f4efa06731a7fd6cc290b81a", size = 3226818, upload-time = "2025-01-02T08:11:22.518Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d8/f6004d98579a2596c098d1e30d10b248798cceff82d2b77aa914875bfea1/pillow-11.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a85b653980faad27e88b141348707ceeef8a1186f75ecc600c395dcac19f385b", size = 3101662, upload-time = "2025-01-02T08:11:25.19Z" }, - { url = "https://files.pythonhosted.org/packages/08/d9/892e705f90051c7a2574d9f24579c9e100c828700d78a63239676f960b74/pillow-11.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9409c080586d1f683df3f184f20e36fb647f2e0bc3988094d4fd8c9f4eb1b3b3", size = 4329317, upload-time = "2025-01-02T08:11:30.371Z" }, - { url = "https://files.pythonhosted.org/packages/8c/aa/7f29711f26680eab0bcd3ecdd6d23ed6bce180d82e3f6380fb7ae35fcf3b/pillow-11.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fdadc077553621911f27ce206ffcbec7d3f8d7b50e0da39f10997e8e2bb7f6a", size = 4412999, upload-time = "2025-01-02T08:11:33.499Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c4/8f0fe3b9e0f7196f6d0bbb151f9fba323d72a41da068610c4c960b16632a/pillow-11.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:93a18841d09bcdd774dcdc308e4537e1f867b3dec059c131fde0327899734aa1", size = 4368819, upload-time = "2025-01-02T08:11:37.304Z" }, - { url = "https://files.pythonhosted.org/packages/38/0d/84200ed6a871ce386ddc82904bfadc0c6b28b0c0ec78176871a4679e40b3/pillow-11.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:9aa9aeddeed452b2f616ff5507459e7bab436916ccb10961c4a382cd3e03f47f", size = 4496081, upload-time = "2025-01-02T08:11:39.598Z" }, - { url = "https://files.pythonhosted.org/packages/84/9c/9bcd66f714d7e25b64118e3952d52841a4babc6d97b6d28e2261c52045d4/pillow-11.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3cdcdb0b896e981678eee140d882b70092dac83ac1cdf6b3a60e2216a73f2b91", size = 4296513, upload-time = "2025-01-02T08:11:43.083Z" }, - { url = "https://files.pythonhosted.org/packages/db/61/ada2a226e22da011b45f7104c95ebda1b63dcbb0c378ad0f7c2a710f8fd2/pillow-11.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:36ba10b9cb413e7c7dfa3e189aba252deee0602c86c309799da5a74009ac7a1c", size = 4431298, upload-time = "2025-01-02T08:11:46.626Z" }, - { url = "https://files.pythonhosted.org/packages/e7/c4/fc6e86750523f367923522014b821c11ebc5ad402e659d8c9d09b3c9d70c/pillow-11.1.0-cp312-cp312-win32.whl", hash = "sha256:cfd5cd998c2e36a862d0e27b2df63237e67273f2fc78f47445b14e73a810e7e6", size = 2291630, upload-time = "2025-01-02T08:11:49.401Z" }, - { url = "https://files.pythonhosted.org/packages/08/5c/2104299949b9d504baf3f4d35f73dbd14ef31bbd1ddc2c1b66a5b7dfda44/pillow-11.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:a697cd8ba0383bba3d2d3ada02b34ed268cb548b369943cd349007730c92bddf", size = 2626369, upload-time = "2025-01-02T08:11:52.02Z" }, - { url = "https://files.pythonhosted.org/packages/37/f3/9b18362206b244167c958984b57c7f70a0289bfb59a530dd8af5f699b910/pillow-11.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:4dd43a78897793f60766563969442020e90eb7847463eca901e41ba186a7d4a5", size = 2375240, upload-time = "2025-01-02T08:11:56.193Z" }, - { url = "https://files.pythonhosted.org/packages/b3/31/9ca79cafdce364fd5c980cd3416c20ce1bebd235b470d262f9d24d810184/pillow-11.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ae98e14432d458fc3de11a77ccb3ae65ddce70f730e7c76140653048c71bfcbc", size = 3226640, upload-time = "2025-01-02T08:11:58.329Z" }, - { url = "https://files.pythonhosted.org/packages/ac/0f/ff07ad45a1f172a497aa393b13a9d81a32e1477ef0e869d030e3c1532521/pillow-11.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cc1331b6d5a6e144aeb5e626f4375f5b7ae9934ba620c0ac6b3e43d5e683a0f0", size = 3101437, upload-time = "2025-01-02T08:12:01.797Z" }, - { url = "https://files.pythonhosted.org/packages/08/2f/9906fca87a68d29ec4530be1f893149e0cb64a86d1f9f70a7cfcdfe8ae44/pillow-11.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:758e9d4ef15d3560214cddbc97b8ef3ef86ce04d62ddac17ad39ba87e89bd3b1", size = 4326605, upload-time = "2025-01-02T08:12:05.224Z" }, - { url = "https://files.pythonhosted.org/packages/b0/0f/f3547ee15b145bc5c8b336401b2d4c9d9da67da9dcb572d7c0d4103d2c69/pillow-11.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b523466b1a31d0dcef7c5be1f20b942919b62fd6e9a9be199d035509cbefc0ec", size = 4411173, upload-time = "2025-01-02T08:12:08.281Z" }, - { url = "https://files.pythonhosted.org/packages/b1/df/bf8176aa5db515c5de584c5e00df9bab0713548fd780c82a86cba2c2fedb/pillow-11.1.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:9044b5e4f7083f209c4e35aa5dd54b1dd5b112b108648f5c902ad586d4f945c5", size = 4369145, upload-time = "2025-01-02T08:12:11.411Z" }, - { url = "https://files.pythonhosted.org/packages/de/7c/7433122d1cfadc740f577cb55526fdc39129a648ac65ce64db2eb7209277/pillow-11.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:3764d53e09cdedd91bee65c2527815d315c6b90d7b8b79759cc48d7bf5d4f114", size = 4496340, upload-time = "2025-01-02T08:12:15.29Z" }, - { url = "https://files.pythonhosted.org/packages/25/46/dd94b93ca6bd555588835f2504bd90c00d5438fe131cf01cfa0c5131a19d/pillow-11.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:31eba6bbdd27dde97b0174ddf0297d7a9c3a507a8a1480e1e60ef914fe23d352", size = 4296906, upload-time = "2025-01-02T08:12:17.485Z" }, - { url = "https://files.pythonhosted.org/packages/a8/28/2f9d32014dfc7753e586db9add35b8a41b7a3b46540e965cb6d6bc607bd2/pillow-11.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b5d658fbd9f0d6eea113aea286b21d3cd4d3fd978157cbf2447a6035916506d3", size = 4431759, upload-time = "2025-01-02T08:12:20.382Z" }, - { url = "https://files.pythonhosted.org/packages/33/48/19c2cbe7403870fbe8b7737d19eb013f46299cdfe4501573367f6396c775/pillow-11.1.0-cp313-cp313-win32.whl", hash = "sha256:f86d3a7a9af5d826744fabf4afd15b9dfef44fe69a98541f666f66fbb8d3fef9", size = 2291657, upload-time = "2025-01-02T08:12:23.922Z" }, - { url = "https://files.pythonhosted.org/packages/3b/ad/285c556747d34c399f332ba7c1a595ba245796ef3e22eae190f5364bb62b/pillow-11.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:593c5fd6be85da83656b93ffcccc2312d2d149d251e98588b14fbc288fd8909c", size = 2626304, upload-time = "2025-01-02T08:12:28.069Z" }, - { url = "https://files.pythonhosted.org/packages/e5/7b/ef35a71163bf36db06e9c8729608f78dedf032fc8313d19bd4be5c2588f3/pillow-11.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:11633d58b6ee5733bde153a8dafd25e505ea3d32e261accd388827ee987baf65", size = 2375117, upload-time = "2025-01-02T08:12:30.064Z" }, - { url = "https://files.pythonhosted.org/packages/79/30/77f54228401e84d6791354888549b45824ab0ffde659bafa67956303a09f/pillow-11.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:70ca5ef3b3b1c4a0812b5c63c57c23b63e53bc38e758b37a951e5bc466449861", size = 3230060, upload-time = "2025-01-02T08:12:32.362Z" }, - { url = "https://files.pythonhosted.org/packages/ce/b1/56723b74b07dd64c1010fee011951ea9c35a43d8020acd03111f14298225/pillow-11.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8000376f139d4d38d6851eb149b321a52bb8893a88dae8ee7d95840431977081", size = 3106192, upload-time = "2025-01-02T08:12:34.361Z" }, - { url = "https://files.pythonhosted.org/packages/e1/cd/7bf7180e08f80a4dcc6b4c3a0aa9e0b0ae57168562726a05dc8aa8fa66b0/pillow-11.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ee85f0696a17dd28fbcfceb59f9510aa71934b483d1f5601d1030c3c8304f3c", size = 4446805, upload-time = "2025-01-02T08:12:36.99Z" }, - { url = "https://files.pythonhosted.org/packages/97/42/87c856ea30c8ed97e8efbe672b58c8304dee0573f8c7cab62ae9e31db6ae/pillow-11.1.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:dd0e081319328928531df7a0e63621caf67652c8464303fd102141b785ef9547", size = 4530623, upload-time = "2025-01-02T08:12:41.912Z" }, - { url = "https://files.pythonhosted.org/packages/ff/41/026879e90c84a88e33fb00cc6bd915ac2743c67e87a18f80270dfe3c2041/pillow-11.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e63e4e5081de46517099dc30abe418122f54531a6ae2ebc8680bcd7096860eab", size = 4465191, upload-time = "2025-01-02T08:12:45.186Z" }, - { url = "https://files.pythonhosted.org/packages/e5/fb/a7960e838bc5df57a2ce23183bfd2290d97c33028b96bde332a9057834d3/pillow-11.1.0-cp313-cp313t-win32.whl", hash = "sha256:dda60aa465b861324e65a78c9f5cf0f4bc713e4309f83bc387be158b077963d9", size = 2295494, upload-time = "2025-01-02T08:12:47.098Z" }, - { url = "https://files.pythonhosted.org/packages/d7/6c/6ec83ee2f6f0fda8d4cf89045c6be4b0373ebfc363ba8538f8c999f63fcd/pillow-11.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ad5db5781c774ab9a9b2c4302bbf0c1014960a0a7be63278d13ae6fdf88126fe", size = 2631595, upload-time = "2025-01-02T08:12:50.47Z" }, - { url = "https://files.pythonhosted.org/packages/cf/6c/41c21c6c8af92b9fea313aa47c75de49e2f9a467964ee33eb0135d47eb64/pillow-11.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:67cd427c68926108778a9005f2a04adbd5e67c442ed21d95389fe1d595458756", size = 2377651, upload-time = "2025-01-02T08:12:53.356Z" }, +version = "11.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/26/77f8ed17ca4ffd60e1dcd220a6ec6d71210ba398cfa33a13a1cd614c5613/pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722", size = 5316531, upload-time = "2025-07-01T09:13:59.203Z" }, + { url = "https://files.pythonhosted.org/packages/cb/39/ee475903197ce709322a17a866892efb560f57900d9af2e55f86db51b0a5/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288", size = 4686560, upload-time = "2025-07-01T09:14:01.101Z" }, + { url = "https://files.pythonhosted.org/packages/d5/90/442068a160fd179938ba55ec8c97050a612426fae5ec0a764e345839f76d/pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d", size = 5870978, upload-time = "2025-07-03T13:09:55.638Z" }, + { url = "https://files.pythonhosted.org/packages/13/92/dcdd147ab02daf405387f0218dcf792dc6dd5b14d2573d40b4caeef01059/pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494", size = 7641168, upload-time = "2025-07-03T13:10:00.37Z" }, + { url = "https://files.pythonhosted.org/packages/6e/db/839d6ba7fd38b51af641aa904e2960e7a5644d60ec754c046b7d2aee00e5/pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58", size = 5973053, upload-time = "2025-07-01T09:14:04.491Z" }, + { url = "https://files.pythonhosted.org/packages/f2/2f/d7675ecae6c43e9f12aa8d58b6012683b20b6edfbdac7abcb4e6af7a3784/pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f", size = 6640273, upload-time = "2025-07-01T09:14:06.235Z" }, + { url = "https://files.pythonhosted.org/packages/45/ad/931694675ede172e15b2ff03c8144a0ddaea1d87adb72bb07655eaffb654/pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e", size = 6082043, upload-time = "2025-07-01T09:14:07.978Z" }, + { url = "https://files.pythonhosted.org/packages/3a/04/ba8f2b11fc80d2dd462d7abec16351b45ec99cbbaea4387648a44190351a/pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94", size = 6715516, upload-time = "2025-07-01T09:14:10.233Z" }, + { url = "https://files.pythonhosted.org/packages/48/59/8cd06d7f3944cc7d892e8533c56b0acb68399f640786313275faec1e3b6f/pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0", size = 6274768, upload-time = "2025-07-01T09:14:11.921Z" }, + { url = "https://files.pythonhosted.org/packages/f1/cc/29c0f5d64ab8eae20f3232da8f8571660aa0ab4b8f1331da5c2f5f9a938e/pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac", size = 6986055, upload-time = "2025-07-01T09:14:13.623Z" }, + { url = "https://files.pythonhosted.org/packages/c6/df/90bd886fabd544c25addd63e5ca6932c86f2b701d5da6c7839387a076b4a/pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd", size = 2423079, upload-time = "2025-07-01T09:14:15.268Z" }, + { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" }, + { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" }, + { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" }, + { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" }, + { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, + { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, + { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, + { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, + { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, + { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, + { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, + { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, + { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, + { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, + { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e3/6fa84033758276fb31da12e5fb66ad747ae83b93c67af17f8c6ff4cc8f34/pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6", size = 5270566, upload-time = "2025-07-01T09:16:19.801Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ee/e8d2e1ab4892970b561e1ba96cbd59c0d28cf66737fc44abb2aec3795a4e/pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438", size = 4654618, upload-time = "2025-07-01T09:16:21.818Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6d/17f80f4e1f0761f02160fc433abd4109fa1548dcfdca46cfdadaf9efa565/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3", size = 4874248, upload-time = "2025-07-03T13:11:20.738Z" }, + { url = "https://files.pythonhosted.org/packages/de/5f/c22340acd61cef960130585bbe2120e2fd8434c214802f07e8c03596b17e/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c", size = 6583963, upload-time = "2025-07-03T13:11:26.283Z" }, + { url = "https://files.pythonhosted.org/packages/31/5e/03966aedfbfcbb4d5f8aa042452d3361f325b963ebbadddac05b122e47dd/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361", size = 4957170, upload-time = "2025-07-01T09:16:23.762Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2d/e082982aacc927fc2cab48e1e731bdb1643a1406acace8bed0900a61464e/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7", size = 5581505, upload-time = "2025-07-01T09:16:25.593Z" }, + { url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598, upload-time = "2025-07-01T09:16:27.732Z" }, ] [[package]] name = "pluggy" -version = "1.5.0" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" }, -] - -[[package]] -name = "portalocker" -version = "2.10.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pywin32", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ed/d3/c6c64067759e87af98cc668c1cc75171347d0f1577fab7ca3749134e3cd4/portalocker-2.10.1.tar.gz", hash = "sha256:ef1bf844e878ab08aee7e40184156e1151f228f103aa5c6bd0724cc330960f8f", size = 40891, upload-time = "2024-07-13T23:15:34.86Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/fb/a70a4214956182e0d7a9099ab17d50bfcba1056188e9b14f35b9e2b62a0d/portalocker-2.10.1-py3-none-any.whl", hash = "sha256:53a5984ebc86a025552264b459b46a2086e269b21823cb572f8f28ee759e45bf", size = 18423, upload-time = "2024-07-13T23:15:32.602Z" }, + { 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 = "propcache" -version = "0.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/c8/2a13f78d82211490855b2fb303b6721348d0787fdd9a12ac46d99d3acde1/propcache-0.2.1.tar.gz", hash = "sha256:3f77ce728b19cb537714499928fe800c3dda29e8d9428778fc7c186da4c09a64", size = 41735, upload-time = "2024-12-01T18:29:16.437Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/0f/2913b6791ebefb2b25b4efd4bb2299c985e09786b9f5b19184a88e5778dd/propcache-0.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ffc3cca89bb438fb9c95c13fc874012f7b9466b89328c3c8b1aa93cdcfadd16", size = 79297, upload-time = "2024-12-01T18:27:39.425Z" }, - { url = "https://files.pythonhosted.org/packages/cf/73/af2053aeccd40b05d6e19058419ac77674daecdd32478088b79375b9ab54/propcache-0.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f174bbd484294ed9fdf09437f889f95807e5f229d5d93588d34e92106fbf6717", size = 45611, upload-time = "2024-12-01T18:27:40.944Z" }, - { url = "https://files.pythonhosted.org/packages/3c/09/8386115ba7775ea3b9537730e8cf718d83bbf95bffe30757ccf37ec4e5da/propcache-0.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:70693319e0b8fd35dd863e3e29513875eb15c51945bf32519ef52927ca883bc3", size = 45146, upload-time = "2024-12-01T18:27:42.106Z" }, - { url = "https://files.pythonhosted.org/packages/03/7a/793aa12f0537b2e520bf09f4c6833706b63170a211ad042ca71cbf79d9cb/propcache-0.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b480c6a4e1138e1aa137c0079b9b6305ec6dcc1098a8ca5196283e8a49df95a9", size = 232136, upload-time = "2024-12-01T18:27:43.293Z" }, - { url = "https://files.pythonhosted.org/packages/f1/38/b921b3168d72111769f648314100558c2ea1d52eb3d1ba7ea5c4aa6f9848/propcache-0.2.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d27b84d5880f6d8aa9ae3edb253c59d9f6642ffbb2c889b78b60361eed449787", size = 239706, upload-time = "2024-12-01T18:27:44.916Z" }, - { url = "https://files.pythonhosted.org/packages/14/29/4636f500c69b5edea7786db3c34eb6166f3384b905665ce312a6e42c720c/propcache-0.2.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:857112b22acd417c40fa4595db2fe28ab900c8c5fe4670c7989b1c0230955465", size = 238531, upload-time = "2024-12-01T18:27:46.228Z" }, - { url = "https://files.pythonhosted.org/packages/85/14/01fe53580a8e1734ebb704a3482b7829a0ef4ea68d356141cf0994d9659b/propcache-0.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf6c4150f8c0e32d241436526f3c3f9cbd34429492abddbada2ffcff506c51af", size = 231063, upload-time = "2024-12-01T18:27:47.72Z" }, - { url = "https://files.pythonhosted.org/packages/33/5c/1d961299f3c3b8438301ccfbff0143b69afcc30c05fa28673cface692305/propcache-0.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66d4cfda1d8ed687daa4bc0274fcfd5267873db9a5bc0418c2da19273040eeb7", size = 220134, upload-time = "2024-12-01T18:27:49.044Z" }, - { url = "https://files.pythonhosted.org/packages/00/d0/ed735e76db279ba67a7d3b45ba4c654e7b02bc2f8050671ec365d8665e21/propcache-0.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2f992c07c0fca81655066705beae35fc95a2fa7366467366db627d9f2ee097f", size = 220009, upload-time = "2024-12-01T18:27:50.343Z" }, - { url = "https://files.pythonhosted.org/packages/75/90/ee8fab7304ad6533872fee982cfff5a53b63d095d78140827d93de22e2d4/propcache-0.2.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:4a571d97dbe66ef38e472703067021b1467025ec85707d57e78711c085984e54", size = 212199, upload-time = "2024-12-01T18:27:52.389Z" }, - { url = "https://files.pythonhosted.org/packages/eb/ec/977ffaf1664f82e90737275873461695d4c9407d52abc2f3c3e24716da13/propcache-0.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bb6178c241278d5fe853b3de743087be7f5f4c6f7d6d22a3b524d323eecec505", size = 214827, upload-time = "2024-12-01T18:27:54.423Z" }, - { url = "https://files.pythonhosted.org/packages/57/48/031fb87ab6081764054821a71b71942161619549396224cbb242922525e8/propcache-0.2.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ad1af54a62ffe39cf34db1aa6ed1a1873bd548f6401db39d8e7cd060b9211f82", size = 228009, upload-time = "2024-12-01T18:27:55.639Z" }, - { url = "https://files.pythonhosted.org/packages/1a/06/ef1390f2524850838f2390421b23a8b298f6ce3396a7cc6d39dedd4047b0/propcache-0.2.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e7048abd75fe40712005bcfc06bb44b9dfcd8e101dda2ecf2f5aa46115ad07ca", size = 231638, upload-time = "2024-12-01T18:27:57.655Z" }, - { url = "https://files.pythonhosted.org/packages/38/2a/101e6386d5a93358395da1d41642b79c1ee0f3b12e31727932b069282b1d/propcache-0.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:160291c60081f23ee43d44b08a7e5fb76681221a8e10b3139618c5a9a291b84e", size = 222788, upload-time = "2024-12-01T18:27:58.917Z" }, - { url = "https://files.pythonhosted.org/packages/db/81/786f687951d0979007e05ad9346cd357e50e3d0b0f1a1d6074df334b1bbb/propcache-0.2.1-cp311-cp311-win32.whl", hash = "sha256:819ce3b883b7576ca28da3861c7e1a88afd08cc8c96908e08a3f4dd64a228034", size = 40170, upload-time = "2024-12-01T18:28:00.307Z" }, - { url = "https://files.pythonhosted.org/packages/cf/59/7cc7037b295d5772eceb426358bb1b86e6cab4616d971bd74275395d100d/propcache-0.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:edc9fc7051e3350643ad929df55c451899bb9ae6d24998a949d2e4c87fb596d3", size = 44404, upload-time = "2024-12-01T18:28:02.129Z" }, - { url = "https://files.pythonhosted.org/packages/4c/28/1d205fe49be8b1b4df4c50024e62480a442b1a7b818e734308bb0d17e7fb/propcache-0.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:081a430aa8d5e8876c6909b67bd2d937bfd531b0382d3fdedb82612c618bc41a", size = 79588, upload-time = "2024-12-01T18:28:03.327Z" }, - { url = "https://files.pythonhosted.org/packages/21/ee/fc4d893f8d81cd4971affef2a6cb542b36617cd1d8ce56b406112cb80bf7/propcache-0.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2ccec9ac47cf4e04897619c0e0c1a48c54a71bdf045117d3a26f80d38ab1fb0", size = 45825, upload-time = "2024-12-01T18:28:06.78Z" }, - { url = "https://files.pythonhosted.org/packages/4a/de/bbe712f94d088da1d237c35d735f675e494a816fd6f54e9db2f61ef4d03f/propcache-0.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:14d86fe14b7e04fa306e0c43cdbeebe6b2c2156a0c9ce56b815faacc193e320d", size = 45357, upload-time = "2024-12-01T18:28:08.575Z" }, - { url = "https://files.pythonhosted.org/packages/7f/14/7ae06a6cf2a2f1cb382586d5a99efe66b0b3d0c6f9ac2f759e6f7af9d7cf/propcache-0.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:049324ee97bb67285b49632132db351b41e77833678432be52bdd0289c0e05e4", size = 241869, upload-time = "2024-12-01T18:28:10.396Z" }, - { url = "https://files.pythonhosted.org/packages/cc/59/227a78be960b54a41124e639e2c39e8807ac0c751c735a900e21315f8c2b/propcache-0.2.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1cd9a1d071158de1cc1c71a26014dcdfa7dd3d5f4f88c298c7f90ad6f27bb46d", size = 247884, upload-time = "2024-12-01T18:28:11.746Z" }, - { url = "https://files.pythonhosted.org/packages/84/58/f62b4ffaedf88dc1b17f04d57d8536601e4e030feb26617228ef930c3279/propcache-0.2.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98110aa363f1bb4c073e8dcfaefd3a5cea0f0834c2aab23dda657e4dab2f53b5", size = 248486, upload-time = "2024-12-01T18:28:13.048Z" }, - { url = "https://files.pythonhosted.org/packages/1c/07/ebe102777a830bca91bbb93e3479cd34c2ca5d0361b83be9dbd93104865e/propcache-0.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:647894f5ae99c4cf6bb82a1bb3a796f6e06af3caa3d32e26d2350d0e3e3faf24", size = 243649, upload-time = "2024-12-01T18:28:14.297Z" }, - { url = "https://files.pythonhosted.org/packages/ed/bc/4f7aba7f08f520376c4bb6a20b9a981a581b7f2e385fa0ec9f789bb2d362/propcache-0.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfd3223c15bebe26518d58ccf9a39b93948d3dcb3e57a20480dfdd315356baff", size = 229103, upload-time = "2024-12-01T18:28:15.913Z" }, - { url = "https://files.pythonhosted.org/packages/fe/d5/04ac9cd4e51a57a96f78795e03c5a0ddb8f23ec098b86f92de028d7f2a6b/propcache-0.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d71264a80f3fcf512eb4f18f59423fe82d6e346ee97b90625f283df56aee103f", size = 226607, upload-time = "2024-12-01T18:28:18.015Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f0/24060d959ea41d7a7cc7fdbf68b31852331aabda914a0c63bdb0e22e96d6/propcache-0.2.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e73091191e4280403bde6c9a52a6999d69cdfde498f1fdf629105247599b57ec", size = 221153, upload-time = "2024-12-01T18:28:19.937Z" }, - { url = "https://files.pythonhosted.org/packages/77/a7/3ac76045a077b3e4de4859a0753010765e45749bdf53bd02bc4d372da1a0/propcache-0.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3935bfa5fede35fb202c4b569bb9c042f337ca4ff7bd540a0aa5e37131659348", size = 222151, upload-time = "2024-12-01T18:28:21.186Z" }, - { url = "https://files.pythonhosted.org/packages/e7/af/5e29da6f80cebab3f5a4dcd2a3240e7f56f2c4abf51cbfcc99be34e17f0b/propcache-0.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f508b0491767bb1f2b87fdfacaba5f7eddc2f867740ec69ece6d1946d29029a6", size = 233812, upload-time = "2024-12-01T18:28:22.816Z" }, - { url = "https://files.pythonhosted.org/packages/8c/89/ebe3ad52642cc5509eaa453e9f4b94b374d81bae3265c59d5c2d98efa1b4/propcache-0.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1672137af7c46662a1c2be1e8dc78cb6d224319aaa40271c9257d886be4363a6", size = 238829, upload-time = "2024-12-01T18:28:24.071Z" }, - { url = "https://files.pythonhosted.org/packages/e9/2f/6b32f273fa02e978b7577159eae7471b3cfb88b48563b1c2578b2d7ca0bb/propcache-0.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b74c261802d3d2b85c9df2dfb2fa81b6f90deeef63c2db9f0e029a3cac50b518", size = 230704, upload-time = "2024-12-01T18:28:25.314Z" }, - { url = "https://files.pythonhosted.org/packages/5c/2e/f40ae6ff5624a5f77edd7b8359b208b5455ea113f68309e2b00a2e1426b6/propcache-0.2.1-cp312-cp312-win32.whl", hash = "sha256:d09c333d36c1409d56a9d29b3a1b800a42c76a57a5a8907eacdbce3f18768246", size = 40050, upload-time = "2024-12-01T18:28:26.617Z" }, - { url = "https://files.pythonhosted.org/packages/3b/77/a92c3ef994e47180862b9d7d11e37624fb1c00a16d61faf55115d970628b/propcache-0.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:c214999039d4f2a5b2073ac506bba279945233da8c786e490d411dfc30f855c1", size = 44117, upload-time = "2024-12-01T18:28:27.643Z" }, - { url = "https://files.pythonhosted.org/packages/0f/2a/329e0547cf2def8857157f9477669043e75524cc3e6251cef332b3ff256f/propcache-0.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aca405706e0b0a44cc6bfd41fbe89919a6a56999157f6de7e182a990c36e37bc", size = 77002, upload-time = "2024-12-01T18:28:29.025Z" }, - { url = "https://files.pythonhosted.org/packages/12/2d/c4df5415e2382f840dc2ecbca0eeb2293024bc28e57a80392f2012b4708c/propcache-0.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:12d1083f001ace206fe34b6bdc2cb94be66d57a850866f0b908972f90996b3e9", size = 44639, upload-time = "2024-12-01T18:28:30.199Z" }, - { url = "https://files.pythonhosted.org/packages/d0/5a/21aaa4ea2f326edaa4e240959ac8b8386ea31dedfdaa636a3544d9e7a408/propcache-0.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d93f3307ad32a27bda2e88ec81134b823c240aa3abb55821a8da553eed8d9439", size = 44049, upload-time = "2024-12-01T18:28:31.308Z" }, - { url = "https://files.pythonhosted.org/packages/4e/3e/021b6cd86c0acc90d74784ccbb66808b0bd36067a1bf3e2deb0f3845f618/propcache-0.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba278acf14471d36316159c94a802933d10b6a1e117b8554fe0d0d9b75c9d536", size = 224819, upload-time = "2024-12-01T18:28:32.755Z" }, - { url = "https://files.pythonhosted.org/packages/3c/57/c2fdeed1b3b8918b1770a133ba5c43ad3d78e18285b0c06364861ef5cc38/propcache-0.2.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4e6281aedfca15301c41f74d7005e6e3f4ca143584ba696ac69df4f02f40d629", size = 229625, upload-time = "2024-12-01T18:28:34.083Z" }, - { url = "https://files.pythonhosted.org/packages/9d/81/70d4ff57bf2877b5780b466471bebf5892f851a7e2ca0ae7ffd728220281/propcache-0.2.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b750a8e5a1262434fb1517ddf64b5de58327f1adc3524a5e44c2ca43305eb0b", size = 232934, upload-time = "2024-12-01T18:28:35.434Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b9/bb51ea95d73b3fb4100cb95adbd4e1acaf2cbb1fd1083f5468eeb4a099a8/propcache-0.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf72af5e0fb40e9babf594308911436c8efde3cb5e75b6f206c34ad18be5c052", size = 227361, upload-time = "2024-12-01T18:28:36.777Z" }, - { url = "https://files.pythonhosted.org/packages/f1/20/3c6d696cd6fd70b29445960cc803b1851a1131e7a2e4ee261ee48e002bcd/propcache-0.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2d0a12018b04f4cb820781ec0dffb5f7c7c1d2a5cd22bff7fb055a2cb19ebce", size = 213904, upload-time = "2024-12-01T18:28:38.041Z" }, - { url = "https://files.pythonhosted.org/packages/a1/cb/1593bfc5ac6d40c010fa823f128056d6bc25b667f5393781e37d62f12005/propcache-0.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e800776a79a5aabdb17dcc2346a7d66d0777e942e4cd251defeb084762ecd17d", size = 212632, upload-time = "2024-12-01T18:28:39.401Z" }, - { url = "https://files.pythonhosted.org/packages/6d/5c/e95617e222be14a34c709442a0ec179f3207f8a2b900273720501a70ec5e/propcache-0.2.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4160d9283bd382fa6c0c2b5e017acc95bc183570cd70968b9202ad6d8fc48dce", size = 207897, upload-time = "2024-12-01T18:28:40.996Z" }, - { url = "https://files.pythonhosted.org/packages/8e/3b/56c5ab3dc00f6375fbcdeefdede5adf9bee94f1fab04adc8db118f0f9e25/propcache-0.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:30b43e74f1359353341a7adb783c8f1b1c676367b011709f466f42fda2045e95", size = 208118, upload-time = "2024-12-01T18:28:42.38Z" }, - { url = "https://files.pythonhosted.org/packages/86/25/d7ef738323fbc6ebcbce33eb2a19c5e07a89a3df2fded206065bd5e868a9/propcache-0.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:58791550b27d5488b1bb52bc96328456095d96206a250d28d874fafe11b3dfaf", size = 217851, upload-time = "2024-12-01T18:28:43.655Z" }, - { url = "https://files.pythonhosted.org/packages/b3/77/763e6cef1852cf1ba740590364ec50309b89d1c818e3256d3929eb92fabf/propcache-0.2.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0f022d381747f0dfe27e99d928e31bc51a18b65bb9e481ae0af1380a6725dd1f", size = 222630, upload-time = "2024-12-01T18:28:45.663Z" }, - { url = "https://files.pythonhosted.org/packages/4f/e9/0f86be33602089c701696fbed8d8c4c07b6ee9605c5b7536fd27ed540c5b/propcache-0.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:297878dc9d0a334358f9b608b56d02e72899f3b8499fc6044133f0d319e2ec30", size = 216269, upload-time = "2024-12-01T18:28:47.602Z" }, - { url = "https://files.pythonhosted.org/packages/cc/02/5ac83217d522394b6a2e81a2e888167e7ca629ef6569a3f09852d6dcb01a/propcache-0.2.1-cp313-cp313-win32.whl", hash = "sha256:ddfab44e4489bd79bda09d84c430677fc7f0a4939a73d2bba3073036f487a0a6", size = 39472, upload-time = "2024-12-01T18:28:48.983Z" }, - { url = "https://files.pythonhosted.org/packages/f4/33/d6f5420252a36034bc8a3a01171bc55b4bff5df50d1c63d9caa50693662f/propcache-0.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:556fc6c10989f19a179e4321e5d678db8eb2924131e64652a51fe83e4c3db0e1", size = 43363, upload-time = "2024-12-01T18:28:50.025Z" }, - { url = "https://files.pythonhosted.org/packages/41/b6/c5319caea262f4821995dca2107483b94a3345d4607ad797c76cb9c36bcc/propcache-0.2.1-py3-none-any.whl", hash = "sha256:52277518d6aae65536e9cea52d4e7fd2f7a66f4aa2d30ed3f2fcea620ace3c54", size = 11818, upload-time = "2024-12-01T18:29:14.716Z" }, +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/8d/e8b436717ab9c2cfc23b116d2c297305aa4cd8339172a456d61ebf5669b8/propcache-0.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0b8d2f607bd8f80ddc04088bc2a037fdd17884a6fcadc47a96e334d72f3717be", size = 74207, upload-time = "2025-06-09T22:54:05.399Z" }, + { url = "https://files.pythonhosted.org/packages/d6/29/1e34000e9766d112171764b9fa3226fa0153ab565d0c242c70e9945318a7/propcache-0.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06766d8f34733416e2e34f46fea488ad5d60726bb9481d3cddf89a6fa2d9603f", size = 43648, upload-time = "2025-06-09T22:54:08.023Z" }, + { url = "https://files.pythonhosted.org/packages/46/92/1ad5af0df781e76988897da39b5f086c2bf0f028b7f9bd1f409bb05b6874/propcache-0.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2dc1f4a1df4fecf4e6f68013575ff4af84ef6f478fe5344317a65d38a8e6dc9", size = 43496, upload-time = "2025-06-09T22:54:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ce/e96392460f9fb68461fabab3e095cb00c8ddf901205be4eae5ce246e5b7e/propcache-0.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be29c4f4810c5789cf10ddf6af80b041c724e629fa51e308a7a0fb19ed1ef7bf", size = 217288, upload-time = "2025-06-09T22:54:10.466Z" }, + { url = "https://files.pythonhosted.org/packages/c5/2a/866726ea345299f7ceefc861a5e782b045545ae6940851930a6adaf1fca6/propcache-0.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59d61f6970ecbd8ff2e9360304d5c8876a6abd4530cb752c06586849ac8a9dc9", size = 227456, upload-time = "2025-06-09T22:54:11.828Z" }, + { url = "https://files.pythonhosted.org/packages/de/03/07d992ccb6d930398689187e1b3c718339a1c06b8b145a8d9650e4726166/propcache-0.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62180e0b8dbb6b004baec00a7983e4cc52f5ada9cd11f48c3528d8cfa7b96a66", size = 225429, upload-time = "2025-06-09T22:54:13.823Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e6/116ba39448753b1330f48ab8ba927dcd6cf0baea8a0ccbc512dfb49ba670/propcache-0.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c144ca294a204c470f18cf4c9d78887810d04a3e2fbb30eea903575a779159df", size = 213472, upload-time = "2025-06-09T22:54:15.232Z" }, + { url = "https://files.pythonhosted.org/packages/a6/85/f01f5d97e54e428885a5497ccf7f54404cbb4f906688a1690cd51bf597dc/propcache-0.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5c2a784234c28854878d68978265617aa6dc0780e53d44b4d67f3651a17a9a2", size = 204480, upload-time = "2025-06-09T22:54:17.104Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/7bf5ab9033b8b8194cc3f7cf1aaa0e9c3256320726f64a3e1f113a812dce/propcache-0.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5745bc7acdafa978ca1642891b82c19238eadc78ba2aaa293c6863b304e552d7", size = 214530, upload-time = "2025-06-09T22:54:18.512Z" }, + { url = "https://files.pythonhosted.org/packages/31/0b/bd3e0c00509b609317df4a18e6b05a450ef2d9a963e1d8bc9c9415d86f30/propcache-0.3.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c0075bf773d66fa8c9d41f66cc132ecc75e5bb9dd7cce3cfd14adc5ca184cb95", size = 205230, upload-time = "2025-06-09T22:54:19.947Z" }, + { url = "https://files.pythonhosted.org/packages/7a/23/fae0ff9b54b0de4e819bbe559508da132d5683c32d84d0dc2ccce3563ed4/propcache-0.3.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5f57aa0847730daceff0497f417c9de353c575d8da3579162cc74ac294c5369e", size = 206754, upload-time = "2025-06-09T22:54:21.716Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/ad6a3c22630aaa5f618b4dc3c3598974a72abb4c18e45a50b3cdd091eb2f/propcache-0.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:eef914c014bf72d18efb55619447e0aecd5fb7c2e3fa7441e2e5d6099bddff7e", size = 218430, upload-time = "2025-06-09T22:54:23.17Z" }, + { url = "https://files.pythonhosted.org/packages/5b/2c/ba4f1c0e8a4b4c75910742f0d333759d441f65a1c7f34683b4a74c0ee015/propcache-0.3.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2a4092e8549031e82facf3decdbc0883755d5bbcc62d3aea9d9e185549936dcf", size = 223884, upload-time = "2025-06-09T22:54:25.539Z" }, + { url = "https://files.pythonhosted.org/packages/88/e4/ebe30fc399e98572019eee82ad0caf512401661985cbd3da5e3140ffa1b0/propcache-0.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85871b050f174bc0bfb437efbdb68aaf860611953ed12418e4361bc9c392749e", size = 211480, upload-time = "2025-06-09T22:54:26.892Z" }, + { url = "https://files.pythonhosted.org/packages/96/0a/7d5260b914e01d1d0906f7f38af101f8d8ed0dc47426219eeaf05e8ea7c2/propcache-0.3.2-cp311-cp311-win32.whl", hash = "sha256:36c8d9b673ec57900c3554264e630d45980fd302458e4ac801802a7fd2ef7897", size = 37757, upload-time = "2025-06-09T22:54:28.241Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2d/89fe4489a884bc0da0c3278c552bd4ffe06a1ace559db5ef02ef24ab446b/propcache-0.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53af8cb6a781b02d2ea079b5b853ba9430fcbe18a8e3ce647d5982a3ff69f39", size = 41500, upload-time = "2025-06-09T22:54:29.4Z" }, + { url = "https://files.pythonhosted.org/packages/a8/42/9ca01b0a6f48e81615dca4765a8f1dd2c057e0540f6116a27dc5ee01dfb6/propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10", size = 73674, upload-time = "2025-06-09T22:54:30.551Z" }, + { url = "https://files.pythonhosted.org/packages/af/6e/21293133beb550f9c901bbece755d582bfaf2176bee4774000bd4dd41884/propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154", size = 43570, upload-time = "2025-06-09T22:54:32.296Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c8/0393a0a3a2b8760eb3bde3c147f62b20044f0ddac81e9d6ed7318ec0d852/propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615", size = 43094, upload-time = "2025-06-09T22:54:33.929Z" }, + { url = "https://files.pythonhosted.org/packages/37/2c/489afe311a690399d04a3e03b069225670c1d489eb7b044a566511c1c498/propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db", size = 226958, upload-time = "2025-06-09T22:54:35.186Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ca/63b520d2f3d418c968bf596839ae26cf7f87bead026b6192d4da6a08c467/propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1", size = 234894, upload-time = "2025-06-09T22:54:36.708Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/1d0ed6fff455a028d678df30cc28dcee7af77fa2b0e6962ce1df95c9a2a9/propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c", size = 233672, upload-time = "2025-06-09T22:54:38.062Z" }, + { url = "https://files.pythonhosted.org/packages/37/7c/54fd5301ef38505ab235d98827207176a5c9b2aa61939b10a460ca53e123/propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67", size = 224395, upload-time = "2025-06-09T22:54:39.634Z" }, + { url = "https://files.pythonhosted.org/packages/ee/1a/89a40e0846f5de05fdc6779883bf46ba980e6df4d2ff8fb02643de126592/propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b", size = 212510, upload-time = "2025-06-09T22:54:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/5e/33/ca98368586c9566a6b8d5ef66e30484f8da84c0aac3f2d9aec6d31a11bd5/propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8", size = 222949, upload-time = "2025-06-09T22:54:43.038Z" }, + { url = "https://files.pythonhosted.org/packages/ba/11/ace870d0aafe443b33b2f0b7efdb872b7c3abd505bfb4890716ad7865e9d/propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251", size = 217258, upload-time = "2025-06-09T22:54:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d2/86fd6f7adffcfc74b42c10a6b7db721d1d9ca1055c45d39a1a8f2a740a21/propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474", size = 213036, upload-time = "2025-06-09T22:54:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/07/94/2d7d1e328f45ff34a0a284cf5a2847013701e24c2a53117e7c280a4316b3/propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535", size = 227684, upload-time = "2025-06-09T22:54:47.63Z" }, + { url = "https://files.pythonhosted.org/packages/b7/05/37ae63a0087677e90b1d14710e532ff104d44bc1efa3b3970fff99b891dc/propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06", size = 234562, upload-time = "2025-06-09T22:54:48.982Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7c/3f539fcae630408d0bd8bf3208b9a647ccad10976eda62402a80adf8fc34/propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1", size = 222142, upload-time = "2025-06-09T22:54:50.424Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d2/34b9eac8c35f79f8a962546b3e97e9d4b990c420ee66ac8255d5d9611648/propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1", size = 37711, upload-time = "2025-06-09T22:54:52.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/61/d582be5d226cf79071681d1b46b848d6cb03d7b70af7063e33a2787eaa03/propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c", size = 41479, upload-time = "2025-06-09T22:54:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d1/8c747fafa558c603c4ca19d8e20b288aa0c7cda74e9402f50f31eb65267e/propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945", size = 71286, upload-time = "2025-06-09T22:54:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/61/99/d606cb7986b60d89c36de8a85d58764323b3a5ff07770a99d8e993b3fa73/propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252", size = 42425, upload-time = "2025-06-09T22:54:55.642Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/ef98f91bbb42b79e9bb82bdd348b255eb9d65f14dbbe3b1594644c4073f7/propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f", size = 41846, upload-time = "2025-06-09T22:54:57.246Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ad/3f0f9a705fb630d175146cd7b1d2bf5555c9beaed54e94132b21aac098a6/propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33", size = 208871, upload-time = "2025-06-09T22:54:58.975Z" }, + { url = "https://files.pythonhosted.org/packages/3a/38/2085cda93d2c8b6ec3e92af2c89489a36a5886b712a34ab25de9fbca7992/propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e", size = 215720, upload-time = "2025-06-09T22:55:00.471Z" }, + { url = "https://files.pythonhosted.org/packages/61/c1/d72ea2dc83ac7f2c8e182786ab0fc2c7bd123a1ff9b7975bee671866fe5f/propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1", size = 215203, upload-time = "2025-06-09T22:55:01.834Z" }, + { url = "https://files.pythonhosted.org/packages/af/81/b324c44ae60c56ef12007105f1460d5c304b0626ab0cc6b07c8f2a9aa0b8/propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3", size = 206365, upload-time = "2025-06-09T22:55:03.199Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/88549128bb89e66d2aff242488f62869014ae092db63ccea53c1cc75a81d/propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1", size = 196016, upload-time = "2025-06-09T22:55:04.518Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/3bdd14e737d145114a5eb83cb172903afba7242f67c5877f9909a20d948d/propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6", size = 205596, upload-time = "2025-06-09T22:55:05.942Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ca/2f4aa819c357d3107c3763d7ef42c03980f9ed5c48c82e01e25945d437c1/propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387", size = 200977, upload-time = "2025-06-09T22:55:07.792Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4a/e65276c7477533c59085251ae88505caf6831c0e85ff8b2e31ebcbb949b1/propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4", size = 197220, upload-time = "2025-06-09T22:55:09.173Z" }, + { url = "https://files.pythonhosted.org/packages/7c/54/fc7152e517cf5578278b242396ce4d4b36795423988ef39bb8cd5bf274c8/propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88", size = 210642, upload-time = "2025-06-09T22:55:10.62Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/abeb4a896d2767bf5f1ea7b92eb7be6a5330645bd7fb844049c0e4045d9d/propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206", size = 212789, upload-time = "2025-06-09T22:55:12.029Z" }, + { url = "https://files.pythonhosted.org/packages/b3/db/ea12a49aa7b2b6d68a5da8293dcf50068d48d088100ac016ad92a6a780e6/propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43", size = 205880, upload-time = "2025-06-09T22:55:13.45Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e5/9076a0bbbfb65d1198007059c65639dfd56266cf8e477a9707e4b1999ff4/propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02", size = 37220, upload-time = "2025-06-09T22:55:15.284Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f5/b369e026b09a26cd77aa88d8fffd69141d2ae00a2abaaf5380d2603f4b7f/propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05", size = 40678, upload-time = "2025-06-09T22:55:16.445Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3a/6ece377b55544941a08d03581c7bc400a3c8cd3c2865900a68d5de79e21f/propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b", size = 76560, upload-time = "2025-06-09T22:55:17.598Z" }, + { url = "https://files.pythonhosted.org/packages/0c/da/64a2bb16418740fa634b0e9c3d29edff1db07f56d3546ca2d86ddf0305e1/propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0", size = 44676, upload-time = "2025-06-09T22:55:18.922Z" }, + { url = "https://files.pythonhosted.org/packages/36/7b/f025e06ea51cb72c52fb87e9b395cced02786610b60a3ed51da8af017170/propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e", size = 44701, upload-time = "2025-06-09T22:55:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/a4/00/faa1b1b7c3b74fc277f8642f32a4c72ba1d7b2de36d7cdfb676db7f4303e/propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28", size = 276934, upload-time = "2025-06-09T22:55:21.5Z" }, + { url = "https://files.pythonhosted.org/packages/74/ab/935beb6f1756e0476a4d5938ff44bf0d13a055fed880caf93859b4f1baf4/propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a", size = 278316, upload-time = "2025-06-09T22:55:22.918Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9d/994a5c1ce4389610838d1caec74bdf0e98b306c70314d46dbe4fcf21a3e2/propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c", size = 282619, upload-time = "2025-06-09T22:55:24.651Z" }, + { url = "https://files.pythonhosted.org/packages/2b/00/a10afce3d1ed0287cef2e09506d3be9822513f2c1e96457ee369adb9a6cd/propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725", size = 265896, upload-time = "2025-06-09T22:55:26.049Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a8/2aa6716ffa566ca57c749edb909ad27884680887d68517e4be41b02299f3/propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892", size = 252111, upload-time = "2025-06-09T22:55:27.381Z" }, + { url = "https://files.pythonhosted.org/packages/36/4f/345ca9183b85ac29c8694b0941f7484bf419c7f0fea2d1e386b4f7893eed/propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44", size = 268334, upload-time = "2025-06-09T22:55:28.747Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ca/fcd54f78b59e3f97b3b9715501e3147f5340167733d27db423aa321e7148/propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe", size = 255026, upload-time = "2025-06-09T22:55:30.184Z" }, + { url = "https://files.pythonhosted.org/packages/8b/95/8e6a6bbbd78ac89c30c225210a5c687790e532ba4088afb8c0445b77ef37/propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81", size = 250724, upload-time = "2025-06-09T22:55:31.646Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b0/0dd03616142baba28e8b2d14ce5df6631b4673850a3d4f9c0f9dd714a404/propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba", size = 268868, upload-time = "2025-06-09T22:55:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/c5/98/2c12407a7e4fbacd94ddd32f3b1e3d5231e77c30ef7162b12a60e2dd5ce3/propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770", size = 271322, upload-time = "2025-06-09T22:55:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/35/91/9cb56efbb428b006bb85db28591e40b7736847b8331d43fe335acf95f6c8/propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330", size = 265778, upload-time = "2025-06-09T22:55:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/b0fe775a2bdd01e176b14b574be679d84fc83958335790f7c9a686c1f468/propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394", size = 41175, upload-time = "2025-06-09T22:55:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ff/47f08595e3d9b5e149c150f88d9714574f1a7cbd89fe2817158a952674bf/propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198", size = 44857, upload-time = "2025-06-09T22:55:39.687Z" }, + { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, ] [[package]] @@ -1289,91 +1420,110 @@ wheels = [ [[package]] name = "pydantic" -version = "2.10.6" +version = "2.11.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, { name = "pydantic-core" }, { name = "typing-extensions" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681, upload-time = "2025-01-24T01:42:12.693Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696, upload-time = "2025-01-24T01:42:10.371Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, ] [[package]] name = "pydantic-core" -version = "2.27.2" +version = "2.33.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443, upload-time = "2024-12-18T11:31:54.917Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/89/f3450af9d09d44eea1f2c369f49e8f181d742f28220f88cc4dfaae91ea6e/pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc", size = 1893421, upload-time = "2024-12-18T11:27:55.409Z" }, - { url = "https://files.pythonhosted.org/packages/9e/e3/71fe85af2021f3f386da42d291412e5baf6ce7716bd7101ea49c810eda90/pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7", size = 1814998, upload-time = "2024-12-18T11:27:57.252Z" }, - { url = "https://files.pythonhosted.org/packages/a6/3c/724039e0d848fd69dbf5806894e26479577316c6f0f112bacaf67aa889ac/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15", size = 1826167, upload-time = "2024-12-18T11:27:59.146Z" }, - { url = "https://files.pythonhosted.org/packages/2b/5b/1b29e8c1fb5f3199a9a57c1452004ff39f494bbe9bdbe9a81e18172e40d3/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306", size = 1865071, upload-time = "2024-12-18T11:28:02.625Z" }, - { url = "https://files.pythonhosted.org/packages/89/6c/3985203863d76bb7d7266e36970d7e3b6385148c18a68cc8915fd8c84d57/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99", size = 2036244, upload-time = "2024-12-18T11:28:04.442Z" }, - { url = "https://files.pythonhosted.org/packages/0e/41/f15316858a246b5d723f7d7f599f79e37493b2e84bfc789e58d88c209f8a/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459", size = 2737470, upload-time = "2024-12-18T11:28:07.679Z" }, - { url = "https://files.pythonhosted.org/packages/a8/7c/b860618c25678bbd6d1d99dbdfdf0510ccb50790099b963ff78a124b754f/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048", size = 1992291, upload-time = "2024-12-18T11:28:10.297Z" }, - { url = "https://files.pythonhosted.org/packages/bf/73/42c3742a391eccbeab39f15213ecda3104ae8682ba3c0c28069fbcb8c10d/pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d", size = 1994613, upload-time = "2024-12-18T11:28:13.362Z" }, - { url = "https://files.pythonhosted.org/packages/94/7a/941e89096d1175d56f59340f3a8ebaf20762fef222c298ea96d36a6328c5/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b", size = 2002355, upload-time = "2024-12-18T11:28:16.587Z" }, - { url = "https://files.pythonhosted.org/packages/6e/95/2359937a73d49e336a5a19848713555605d4d8d6940c3ec6c6c0ca4dcf25/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474", size = 2126661, upload-time = "2024-12-18T11:28:18.407Z" }, - { url = "https://files.pythonhosted.org/packages/2b/4c/ca02b7bdb6012a1adef21a50625b14f43ed4d11f1fc237f9d7490aa5078c/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6", size = 2153261, upload-time = "2024-12-18T11:28:21.471Z" }, - { url = "https://files.pythonhosted.org/packages/72/9d/a241db83f973049a1092a079272ffe2e3e82e98561ef6214ab53fe53b1c7/pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c", size = 1812361, upload-time = "2024-12-18T11:28:23.53Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ef/013f07248041b74abd48a385e2110aa3a9bbfef0fbd97d4e6d07d2f5b89a/pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc", size = 1982484, upload-time = "2024-12-18T11:28:25.391Z" }, - { url = "https://files.pythonhosted.org/packages/10/1c/16b3a3e3398fd29dca77cea0a1d998d6bde3902fa2706985191e2313cc76/pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4", size = 1867102, upload-time = "2024-12-18T11:28:28.593Z" }, - { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127, upload-time = "2024-12-18T11:28:30.346Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340, upload-time = "2024-12-18T11:28:32.521Z" }, - { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900, upload-time = "2024-12-18T11:28:34.507Z" }, - { url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177, upload-time = "2024-12-18T11:28:36.488Z" }, - { url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046, upload-time = "2024-12-18T11:28:39.409Z" }, - { url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386, upload-time = "2024-12-18T11:28:41.221Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060, upload-time = "2024-12-18T11:28:44.709Z" }, - { url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870, upload-time = "2024-12-18T11:28:46.839Z" }, - { url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822, upload-time = "2024-12-18T11:28:48.896Z" }, - { url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364, upload-time = "2024-12-18T11:28:50.755Z" }, - { url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303, upload-time = "2024-12-18T11:28:54.122Z" }, - { url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064, upload-time = "2024-12-18T11:28:56.074Z" }, - { url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046, upload-time = "2024-12-18T11:28:58.107Z" }, - { url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092, upload-time = "2024-12-18T11:29:01.335Z" }, - { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709, upload-time = "2024-12-18T11:29:03.193Z" }, - { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273, upload-time = "2024-12-18T11:29:05.306Z" }, - { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027, upload-time = "2024-12-18T11:29:07.294Z" }, - { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888, upload-time = "2024-12-18T11:29:09.249Z" }, - { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738, upload-time = "2024-12-18T11:29:11.23Z" }, - { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138, upload-time = "2024-12-18T11:29:16.396Z" }, - { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025, upload-time = "2024-12-18T11:29:20.25Z" }, - { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633, upload-time = "2024-12-18T11:29:23.877Z" }, - { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404, upload-time = "2024-12-18T11:29:25.872Z" }, - { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130, upload-time = "2024-12-18T11:29:29.252Z" }, - { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946, upload-time = "2024-12-18T11:29:31.338Z" }, - { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387, upload-time = "2024-12-18T11:29:33.481Z" }, - { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453, upload-time = "2024-12-18T11:29:35.533Z" }, - { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186, upload-time = "2024-12-18T11:29:37.649Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" }, + { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" }, + { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" }, + { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" }, + { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" }, + { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" }, + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, + { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" }, + { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" }, + { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" }, + { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" }, + { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, ] [[package]] name = "pydantic-settings" -version = "2.7.1" +version = "2.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/73/7b/c58a586cd7d9ac66d2ee4ba60ca2d241fa837c02bca9bea80a9a8c3d22a9/pydantic_settings-2.7.1.tar.gz", hash = "sha256:10c9caad35e64bfb3c2fbf70a078c0e25cc92499782e5200747f942a065dec93", size = 79920, upload-time = "2024-12-31T11:27:44.632Z" } +sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/46/93416fdae86d40879714f72956ac14df9c7b76f7d41a4d68aa9f71a0028b/pydantic_settings-2.7.1-py3-none-any.whl", hash = "sha256:590be9e6e24d06db33a4262829edef682500ef008565a969c73d39d5f8bfb3fd", size = 29718, upload-time = "2024-12-31T11:27:43.201Z" }, + { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, ] [[package]] name = "pygments" -version = "2.19.1" +version = "2.19.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] [[package]] @@ -1392,74 +1542,75 @@ crypto = [ [[package]] name = "pypdfium2" -version = "4.30.1" +version = "4.30.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/55/d4/905e621c62598a08168c272b42fc00136c8861cfce97afb2a1ecbd99487a/pypdfium2-4.30.1.tar.gz", hash = "sha256:5f5c7c6d03598e107d974f66b220a49436aceb191da34cda5f692be098a814ce", size = 164854, upload-time = "2024-12-19T19:28:11.459Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/14/838b3ba247a0ba92e4df5d23f2bea9478edcfd72b78a39d6ca36ccd84ad2/pypdfium2-4.30.0.tar.gz", hash = "sha256:48b5b7e5566665bc1015b9d69c1ebabe21f6aee468b509531c3c8318eeee2e16", size = 140239, upload-time = "2024-05-09T18:33:17.552Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/30/8e/3ce0856b3af0f058dd3655ce57d31d1dbde4d4bd0e172022ffbf1b58a4b9/pypdfium2-4.30.1-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:e07c47633732cc18d890bb7e965ad28a9c5a932e548acb928596f86be2e5ae37", size = 2889836, upload-time = "2024-12-19T19:27:39.531Z" }, - { url = "https://files.pythonhosted.org/packages/c2/6a/f6995b21f9c6c155487ce7df70632a2df1ba49efcb291b9943ea45f28b15/pypdfium2-4.30.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5ea2d44e96d361123b67b00f527017aa9c847c871b5714e013c01c3eb36a79fe", size = 2769232, upload-time = "2024-12-19T19:27:43.227Z" }, - { url = "https://files.pythonhosted.org/packages/53/91/79060923148e6d380b8a299b32bba46d70aac5fe1cd4f04320bcbd1a48d3/pypdfium2-4.30.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1de7a3a36803171b3f66911131046d65a732f9e7834438191cb58235e6163c4e", size = 2847531, upload-time = "2024-12-19T19:27:46.372Z" }, - { url = "https://files.pythonhosted.org/packages/a8/6c/93507f87c159e747eaab54352c0fccbaec3f1b3749d0bb9085a47899f898/pypdfium2-4.30.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b8a4231efb13170354f568c722d6540b8d5b476b08825586d48ef70c40d16e03", size = 2636266, upload-time = "2024-12-19T19:27:49.767Z" }, - { url = "https://files.pythonhosted.org/packages/24/dc/d56f74a092f2091e328d6485f16562e2fc51cffb0ad6d5c616d80c1eb53c/pypdfium2-4.30.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f434a4934e8244aa95343ffcf24e9ad9f120dbb4785f631bb40a88c39292493", size = 2919296, upload-time = "2024-12-19T19:27:51.767Z" }, - { url = "https://files.pythonhosted.org/packages/be/d9/a2f1ee03d47fbeb48bcfde47ed7155772739622cfadf7135a84ba6a97824/pypdfium2-4.30.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f454032a0bc7681900170f67d8711b3942824531e765f91c2f5ce7937f999794", size = 2866119, upload-time = "2024-12-19T19:27:53.561Z" }, - { url = "https://files.pythonhosted.org/packages/01/47/6aa019c32aa39d3f33347c458c0c5887e84096cbe444456402bc97e66704/pypdfium2-4.30.1-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:bbf9130a72370ee9d602e39949b902db669a2a1c24746a91e5586eb829055d9f", size = 6228684, upload-time = "2024-12-19T19:27:56.781Z" }, - { url = "https://files.pythonhosted.org/packages/4c/07/2954c15b3f7c85ceb80cad36757fd41b3aba0dd14e68f4bed9ce3f2e7e74/pypdfium2-4.30.1-py3-none-musllinux_1_1_i686.whl", hash = "sha256:5cb52884b1583b96e94fd78542c63bb42e06df5e8f9e52f8f31f5ad5a1e53367", size = 6231815, upload-time = "2024-12-19T19:28:00.351Z" }, - { url = "https://files.pythonhosted.org/packages/b4/9b/b4667e95754624f4af5a912001abba90c046e1c80d4a4e887f0af664ffec/pypdfium2-4.30.1-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:1a9e372bd4867ff223cc8c338e33fe11055dad12f22885950fc27646cc8d9122", size = 6313429, upload-time = "2024-12-19T19:28:02.536Z" }, - { url = "https://files.pythonhosted.org/packages/43/38/f9e77cf55ba5546a39fa659404b78b97de2ca344848271e7731efb0954cd/pypdfium2-4.30.1-py3-none-win32.whl", hash = "sha256:421f1cf205e213e07c1f2934905779547f4f4a2ff2f59dde29da3d511d3fc806", size = 2834989, upload-time = "2024-12-19T19:28:04.657Z" }, - { url = "https://files.pythonhosted.org/packages/a4/f3/8d3a350efb4286b5ebdabcf6736f51d8e3b10dbe68804c6930b00f5cf329/pypdfium2-4.30.1-py3-none-win_amd64.whl", hash = "sha256:598a7f20264ab5113853cba6d86c4566e4356cad037d7d1f849c8c9021007e05", size = 2960157, upload-time = "2024-12-19T19:28:07.772Z" }, - { url = "https://files.pythonhosted.org/packages/e1/6b/2706497c86e8d69fb76afe5ea857fe1794621aa0f3b1d863feb953fe0f22/pypdfium2-4.30.1-py3-none-win_arm64.whl", hash = "sha256:c2b6d63f6d425d9416c08d2511822b54b8e3ac38e639fc41164b1d75584b3a8c", size = 2814810, upload-time = "2024-12-19T19:28:09.857Z" }, + { url = "https://files.pythonhosted.org/packages/c7/9a/c8ff5cc352c1b60b0b97642ae734f51edbab6e28b45b4fcdfe5306ee3c83/pypdfium2-4.30.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:b33ceded0b6ff5b2b93bc1fe0ad4b71aa6b7e7bd5875f1ca0cdfb6ba6ac01aab", size = 2837254, upload-time = "2024-05-09T18:32:48.653Z" }, + { url = "https://files.pythonhosted.org/packages/21/8b/27d4d5409f3c76b985f4ee4afe147b606594411e15ac4dc1c3363c9a9810/pypdfium2-4.30.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4e55689f4b06e2d2406203e771f78789bd4f190731b5d57383d05cf611d829de", size = 2707624, upload-time = "2024-05-09T18:32:51.458Z" }, + { url = "https://files.pythonhosted.org/packages/11/63/28a73ca17c24b41a205d658e177d68e198d7dde65a8c99c821d231b6ee3d/pypdfium2-4.30.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e6e50f5ce7f65a40a33d7c9edc39f23140c57e37144c2d6d9e9262a2a854854", size = 2793126, upload-time = "2024-05-09T18:32:53.581Z" }, + { url = "https://files.pythonhosted.org/packages/d1/96/53b3ebf0955edbd02ac6da16a818ecc65c939e98fdeb4e0958362bd385c8/pypdfium2-4.30.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3d0dd3ecaffd0b6dbda3da663220e705cb563918249bda26058c6036752ba3a2", size = 2591077, upload-time = "2024-05-09T18:32:55.99Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ee/0394e56e7cab8b5b21f744d988400948ef71a9a892cbeb0b200d324ab2c7/pypdfium2-4.30.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc3bf29b0db8c76cdfaac1ec1cde8edf211a7de7390fbf8934ad2aa9b4d6dfad", size = 2864431, upload-time = "2024-05-09T18:32:57.911Z" }, + { url = "https://files.pythonhosted.org/packages/65/cd/3f1edf20a0ef4a212a5e20a5900e64942c5a374473671ac0780eaa08ea80/pypdfium2-4.30.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1f78d2189e0ddf9ac2b7a9b9bd4f0c66f54d1389ff6c17e9fd9dc034d06eb3f", size = 2812008, upload-time = "2024-05-09T18:32:59.886Z" }, + { url = "https://files.pythonhosted.org/packages/c8/91/2d517db61845698f41a2a974de90762e50faeb529201c6b3574935969045/pypdfium2-4.30.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:5eda3641a2da7a7a0b2f4dbd71d706401a656fea521b6b6faa0675b15d31a163", size = 6181543, upload-time = "2024-05-09T18:33:02.597Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c4/ed1315143a7a84b2c7616569dfb472473968d628f17c231c39e29ae9d780/pypdfium2-4.30.0-py3-none-musllinux_1_1_i686.whl", hash = "sha256:0dfa61421b5eb68e1188b0b2231e7ba35735aef2d867d86e48ee6cab6975195e", size = 6175911, upload-time = "2024-05-09T18:33:05.376Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c4/9e62d03f414e0e3051c56d5943c3bf42aa9608ede4e19dc96438364e9e03/pypdfium2-4.30.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:f33bd79e7a09d5f7acca3b0b69ff6c8a488869a7fab48fdf400fec6e20b9c8be", size = 6267430, upload-time = "2024-05-09T18:33:08.067Z" }, + { url = "https://files.pythonhosted.org/packages/90/47/eda4904f715fb98561e34012826e883816945934a851745570521ec89520/pypdfium2-4.30.0-py3-none-win32.whl", hash = "sha256:ee2410f15d576d976c2ab2558c93d392a25fb9f6635e8dd0a8a3a5241b275e0e", size = 2775951, upload-time = "2024-05-09T18:33:10.567Z" }, + { url = "https://files.pythonhosted.org/packages/25/bd/56d9ec6b9f0fc4e0d95288759f3179f0fcd34b1a1526b75673d2f6d5196f/pypdfium2-4.30.0-py3-none-win_amd64.whl", hash = "sha256:90dbb2ac07be53219f56be09961eb95cf2473f834d01a42d901d13ccfad64b4c", size = 2892098, upload-time = "2024-05-09T18:33:13.107Z" }, + { url = "https://files.pythonhosted.org/packages/be/7a/097801205b991bc3115e8af1edb850d30aeaf0118520b016354cf5ccd3f6/pypdfium2-4.30.0-py3-none-win_arm64.whl", hash = "sha256:119b2969a6d6b1e8d55e99caaf05290294f2d0fe49c12a3f17102d01c441bd29", size = 2752118, upload-time = "2024-05-09T18:33:15.489Z" }, ] [[package]] name = "pyright" -version = "1.1.394" +version = "1.1.404" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/e4/79f4d8a342eed6790fdebdb500e95062f319ee3d7d75ae27304ff995ae8c/pyright-1.1.394.tar.gz", hash = "sha256:56f2a3ab88c5214a451eb71d8f2792b7700434f841ea219119ade7f42ca93608", size = 3809348, upload-time = "2025-02-12T10:27:26.981Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/6e/026be64c43af681d5632722acd100b06d3d39f383ec382ff50a71a6d5bce/pyright-1.1.404.tar.gz", hash = "sha256:455e881a558ca6be9ecca0b30ce08aa78343ecc031d37a198ffa9a7a1abeb63e", size = 4065679, upload-time = "2025-08-20T18:46:14.029Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/4c/50c74e3d589517a9712a61a26143b587dba6285434a17aebf2ce6b82d2c3/pyright-1.1.394-py3-none-any.whl", hash = "sha256:5f74cce0a795a295fb768759bbeeec62561215dea657edcaab48a932b031ddbb", size = 5679540, upload-time = "2025-02-12T10:27:24.833Z" }, + { url = "https://files.pythonhosted.org/packages/84/30/89aa7f7d7a875bbb9a577d4b1dc5a3e404e3d2ae2657354808e905e358e0/pyright-1.1.404-py3-none-any.whl", hash = "sha256:c7b7ff1fdb7219c643079e4c3e7d4125f0dafcc19d253b47e898d130ea426419", size = 5902951, upload-time = "2025-08-20T18:46:12.096Z" }, ] [[package]] name = "pytest" -version = "8.3.5" +version = "8.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, + { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } +sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, + { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, ] [[package]] name = "pytest-asyncio" -version = "0.25.3" +version = "1.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f2/a8/ecbc8ede70921dd2f544ab1cadd3ff3bf842af27f87bbdea774c7baa1d38/pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a", size = 54239, upload-time = "2025-01-28T18:37:58.729Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/51/f8794af39eeb870e87a8c8068642fc07bce0c854d6865d7dd0f2a9d338c2/pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea", size = 46652, upload-time = "2025-07-16T04:29:26.393Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/17/3493c5624e48fd97156ebaec380dcaafee9506d7e2c46218ceebbb57d7de/pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3", size = 19467, upload-time = "2025-01-28T18:37:56.798Z" }, + { url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" }, ] [[package]] name = "pytest-repeat" -version = "0.9.3" +version = "0.9.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/86/5e/99365eb229efff0b1bd475886150fc6db9937ab7e1bd21f6f65c1279e0eb/pytest_repeat-0.9.3.tar.gz", hash = "sha256:ffd3836dfcd67bb270bec648b330e20be37d2966448c4148c4092d1e8aba8185", size = 6272, upload-time = "2023-10-09T19:24:13.809Z" } +sdist = { url = "https://files.pythonhosted.org/packages/80/d4/69e9dbb9b8266df0b157c72be32083403c412990af15c7c15f7a3fd1b142/pytest_repeat-0.9.4.tar.gz", hash = "sha256:d92ac14dfaa6ffcfe6917e5d16f0c9bc82380c135b03c2a5f412d2637f224485", size = 6488, upload-time = "2025-04-07T14:59:53.077Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/a8/0a0aec0c2541b8baf4a0b95af2ba99abce217ee43534adf9cb7c908cf184/pytest_repeat-0.9.3-py3-none-any.whl", hash = "sha256:26ab2df18226af9d5ce441c858f273121e92ff55f5bb311d25755b8d7abdd8ed", size = 4196, upload-time = "2023-10-09T19:24:12.315Z" }, + { url = "https://files.pythonhosted.org/packages/73/d4/8b706b81b07b43081bd68a2c0359fe895b74bf664b20aca8005d2bb3be71/pytest_repeat-0.9.4-py3-none-any.whl", hash = "sha256:c1738b4e412a6f3b3b9e0b8b29fcd7a423e50f87381ad9307ef6f5a8601139f3", size = 4180, upload-time = "2025-04-07T14:59:51.492Z" }, ] [[package]] @@ -1476,25 +1627,25 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.0.1" +version = "1.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115, upload-time = "2024-01-23T06:33:00.505Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload-time = "2024-01-23T06:32:58.246Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, ] [[package]] name = "python-json-logger" -version = "3.2.1" +version = "3.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/c4/358cd13daa1d912ef795010897a483ab2f0b41c9ea1b35235a8b2f7d15a7/python_json_logger-3.2.1.tar.gz", hash = "sha256:8eb0554ea17cb75b05d2848bc14fb02fbdbd9d6972120781b974380bfa162008", size = 16287, upload-time = "2024-12-16T06:48:05.882Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/de/d3144a0bceede957f961e975f3752760fbe390d57fbe194baf709d8f1f7b/python_json_logger-3.3.0.tar.gz", hash = "sha256:12b7e74b17775e7d565129296105bbe3910842d9d0eb083fc83a6a617aa8df84", size = 16642, upload-time = "2025-03-07T07:08:27.301Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/72/2f30cf26664fcfa0bd8ec5ee62ec90c03bd485e4a294d92aabc76c5203a5/python_json_logger-3.2.1-py3-none-any.whl", hash = "sha256:cdc17047eb5374bd311e748b42f99d71223f3b0e186f4206cc5d52aefe85b090", size = 14924, upload-time = "2024-12-16T06:48:03.25Z" }, + { url = "https://files.pythonhosted.org/packages/08/20/0f2523b9e50a8052bc6a8b732dfc8568abbdc42010aef03a2d750bdab3b2/python_json_logger-3.3.0-py3-none-any.whl", hash = "sha256:dd980fae8cffb24c13caf6e158d3d61c0d6d22342f932cb6e9deedab3d35eec7", size = 15163, upload-time = "2025-03-07T07:08:25.627Z" }, ] [[package]] name = "python-liquid" -version = "2.0.2" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "babel" }, @@ -1502,9 +1653,9 @@ dependencies = [ { name = "python-dateutil" }, { name = "pytz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/29/45ea9b5b22394aaea4d981960531c1de8cc46b9f4f85ddef3ff180740a8d/python_liquid-2.0.2.tar.gz", hash = "sha256:3b10c3716829778284e545afa968252bc51bc67ad8d13b3d93975d3affa92e2e", size = 91642, upload-time = "2025-05-21T07:38:16.108Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/46/3b2731966bf24a1cab027eae3c87c41e379750a7dd8c7041c37b9a29d168/python_liquid-2.1.0.tar.gz", hash = "sha256:a4c2abb24ac40ded8c9ba844ebbfbe78a3e41c6fe10a7bbe94144582569b73d0", size = 93152, upload-time = "2025-08-15T07:33:26.019Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/64/4e7d316c3ebaaae8a5f6fdbfa5067285f66bceed66ef002b21ac3033049d/python_liquid-2.0.2-py3-none-any.whl", hash = "sha256:599ac3f0fcfc77c914447a0c53f87a55b537ffe5d0169f2fffdc8216577161c0", size = 136282, upload-time = "2025-05-21T07:38:14.856Z" }, + { url = "https://files.pythonhosted.org/packages/c0/0d/8c0cc6895ff2ec26b963f055ff2514596e71b509cde3b9ffbbf0f7f59995/python_liquid-2.1.0-py3-none-any.whl", hash = "sha256:d3bbcddff4e1a73287b59218df3471613598271e69ac3d17d97e000f4b984e3e", size = 137984, upload-time = "2025-08-15T07:33:24.274Z" }, ] [[package]] @@ -1525,22 +1676,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, ] -[[package]] -name = "pywin32" -version = "308" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/e2/02652007469263fe1466e98439831d65d4ca80ea1a2df29abecedf7e47b7/pywin32-308-cp311-cp311-win32.whl", hash = "sha256:5d8c8015b24a7d6855b1550d8e660d8daa09983c80e5daf89a273e5c6fb5095a", size = 5928156, upload-time = "2024-10-12T20:42:05.78Z" }, - { url = "https://files.pythonhosted.org/packages/48/ef/f4fb45e2196bc7ffe09cad0542d9aff66b0e33f6c0954b43e49c33cad7bd/pywin32-308-cp311-cp311-win_amd64.whl", hash = "sha256:575621b90f0dc2695fec346b2d6302faebd4f0f45c05ea29404cefe35d89442b", size = 6559559, upload-time = "2024-10-12T20:42:07.644Z" }, - { url = "https://files.pythonhosted.org/packages/79/ef/68bb6aa865c5c9b11a35771329e95917b5559845bd75b65549407f9fc6b4/pywin32-308-cp311-cp311-win_arm64.whl", hash = "sha256:100a5442b7332070983c4cd03f2e906a5648a5104b8a7f50175f7906efd16bb6", size = 7972495, upload-time = "2024-10-12T20:42:09.803Z" }, - { url = "https://files.pythonhosted.org/packages/00/7c/d00d6bdd96de4344e06c4afbf218bc86b54436a94c01c71a8701f613aa56/pywin32-308-cp312-cp312-win32.whl", hash = "sha256:587f3e19696f4bf96fde9d8a57cec74a57021ad5f204c9e627e15c33ff568897", size = 5939729, upload-time = "2024-10-12T20:42:12.001Z" }, - { url = "https://files.pythonhosted.org/packages/21/27/0c8811fbc3ca188f93b5354e7c286eb91f80a53afa4e11007ef661afa746/pywin32-308-cp312-cp312-win_amd64.whl", hash = "sha256:00b3e11ef09ede56c6a43c71f2d31857cf7c54b0ab6e78ac659497abd2834f47", size = 6543015, upload-time = "2024-10-12T20:42:14.044Z" }, - { url = "https://files.pythonhosted.org/packages/9d/0f/d40f8373608caed2255781a3ad9a51d03a594a1248cd632d6a298daca693/pywin32-308-cp312-cp312-win_arm64.whl", hash = "sha256:9b4de86c8d909aed15b7011182c8cab38c8850de36e6afb1f0db22b8959e3091", size = 7976033, upload-time = "2024-10-12T20:42:16.215Z" }, - { url = "https://files.pythonhosted.org/packages/a9/a4/aa562d8935e3df5e49c161b427a3a2efad2ed4e9cf81c3de636f1fdddfd0/pywin32-308-cp313-cp313-win32.whl", hash = "sha256:1c44539a37a5b7b21d02ab34e6a4d314e0788f1690d65b48e9b0b89f31abbbed", size = 5938579, upload-time = "2024-10-12T20:42:18.623Z" }, - { url = "https://files.pythonhosted.org/packages/c7/50/b0efb8bb66210da67a53ab95fd7a98826a97ee21f1d22949863e6d588b22/pywin32-308-cp313-cp313-win_amd64.whl", hash = "sha256:fd380990e792eaf6827fcb7e187b2b4b1cede0585e3d0c9e84201ec27b9905e4", size = 6542056, upload-time = "2024-10-12T20:42:20.864Z" }, - { url = "https://files.pythonhosted.org/packages/26/df/2b63e3e4f2df0224f8aaf6d131f54fe4e8c96400eb9df563e2aae2e1a1f9/pywin32-308-cp313-cp313-win_arm64.whl", hash = "sha256:ef313c46d4c18dfb82a2431e3051ac8f112ccee1a34f29c263c583c568db63cd", size = 7974986, upload-time = "2024-10-12T20:42:22.799Z" }, -] - [[package]] name = "pyyaml" version = "6.0.2" @@ -1578,60 +1713,71 @@ wheels = [ [[package]] name = "regex" -version = "2024.11.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/5f/bd69653fbfb76cf8604468d3b4ec4c403197144c7bfe0e6a5fc9e02a07cb/regex-2024.11.6.tar.gz", hash = "sha256:7ab159b063c52a0333c884e4679f8d7a85112ee3078fe3d9004b2dd875585519", size = 399494, upload-time = "2024-11-06T20:12:31.635Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/58/7e4d9493a66c88a7da6d205768119f51af0f684fe7be7bac8328e217a52c/regex-2024.11.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5478c6962ad548b54a591778e93cd7c456a7a29f8eca9c49e4f9a806dcc5d638", size = 482669, upload-time = "2024-11-06T20:09:31.064Z" }, - { url = "https://files.pythonhosted.org/packages/34/4c/8f8e631fcdc2ff978609eaeef1d6994bf2f028b59d9ac67640ed051f1218/regex-2024.11.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c89a8cc122b25ce6945f0423dc1352cb9593c68abd19223eebbd4e56612c5b7", size = 287684, upload-time = "2024-11-06T20:09:32.915Z" }, - { url = "https://files.pythonhosted.org/packages/c5/1b/f0e4d13e6adf866ce9b069e191f303a30ab1277e037037a365c3aad5cc9c/regex-2024.11.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94d87b689cdd831934fa3ce16cc15cd65748e6d689f5d2b8f4f4df2065c9fa20", size = 284589, upload-time = "2024-11-06T20:09:35.504Z" }, - { url = "https://files.pythonhosted.org/packages/25/4d/ab21047f446693887f25510887e6820b93f791992994f6498b0318904d4a/regex-2024.11.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1062b39a0a2b75a9c694f7a08e7183a80c63c0d62b301418ffd9c35f55aaa114", size = 792121, upload-time = "2024-11-06T20:09:37.701Z" }, - { url = "https://files.pythonhosted.org/packages/45/ee/c867e15cd894985cb32b731d89576c41a4642a57850c162490ea34b78c3b/regex-2024.11.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:167ed4852351d8a750da48712c3930b031f6efdaa0f22fa1933716bfcd6bf4a3", size = 831275, upload-time = "2024-11-06T20:09:40.371Z" }, - { url = "https://files.pythonhosted.org/packages/b3/12/b0f480726cf1c60f6536fa5e1c95275a77624f3ac8fdccf79e6727499e28/regex-2024.11.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d548dafee61f06ebdb584080621f3e0c23fff312f0de1afc776e2a2ba99a74f", size = 818257, upload-time = "2024-11-06T20:09:43.059Z" }, - { url = "https://files.pythonhosted.org/packages/bf/ce/0d0e61429f603bac433910d99ef1a02ce45a8967ffbe3cbee48599e62d88/regex-2024.11.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2a19f302cd1ce5dd01a9099aaa19cae6173306d1302a43b627f62e21cf18ac0", size = 792727, upload-time = "2024-11-06T20:09:48.19Z" }, - { url = "https://files.pythonhosted.org/packages/e4/c1/243c83c53d4a419c1556f43777ccb552bccdf79d08fda3980e4e77dd9137/regex-2024.11.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bec9931dfb61ddd8ef2ebc05646293812cb6b16b60cf7c9511a832b6f1854b55", size = 780667, upload-time = "2024-11-06T20:09:49.828Z" }, - { url = "https://files.pythonhosted.org/packages/c5/f4/75eb0dd4ce4b37f04928987f1d22547ddaf6c4bae697623c1b05da67a8aa/regex-2024.11.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9714398225f299aa85267fd222f7142fcb5c769e73d7733344efc46f2ef5cf89", size = 776963, upload-time = "2024-11-06T20:09:51.819Z" }, - { url = "https://files.pythonhosted.org/packages/16/5d/95c568574e630e141a69ff8a254c2f188b4398e813c40d49228c9bbd9875/regex-2024.11.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:202eb32e89f60fc147a41e55cb086db2a3f8cb82f9a9a88440dcfc5d37faae8d", size = 784700, upload-time = "2024-11-06T20:09:53.982Z" }, - { url = "https://files.pythonhosted.org/packages/8e/b5/f8495c7917f15cc6fee1e7f395e324ec3e00ab3c665a7dc9d27562fd5290/regex-2024.11.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4181b814e56078e9b00427ca358ec44333765f5ca1b45597ec7446d3a1ef6e34", size = 848592, upload-time = "2024-11-06T20:09:56.222Z" }, - { url = "https://files.pythonhosted.org/packages/1c/80/6dd7118e8cb212c3c60b191b932dc57db93fb2e36fb9e0e92f72a5909af9/regex-2024.11.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:068376da5a7e4da51968ce4c122a7cd31afaaec4fccc7856c92f63876e57b51d", size = 852929, upload-time = "2024-11-06T20:09:58.642Z" }, - { url = "https://files.pythonhosted.org/packages/11/9b/5a05d2040297d2d254baf95eeeb6df83554e5e1df03bc1a6687fc4ba1f66/regex-2024.11.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ac10f2c4184420d881a3475fb2c6f4d95d53a8d50209a2500723d831036f7c45", size = 781213, upload-time = "2024-11-06T20:10:00.867Z" }, - { url = "https://files.pythonhosted.org/packages/26/b7/b14e2440156ab39e0177506c08c18accaf2b8932e39fb092074de733d868/regex-2024.11.6-cp311-cp311-win32.whl", hash = "sha256:c36f9b6f5f8649bb251a5f3f66564438977b7ef8386a52460ae77e6070d309d9", size = 261734, upload-time = "2024-11-06T20:10:03.361Z" }, - { url = "https://files.pythonhosted.org/packages/80/32/763a6cc01d21fb3819227a1cc3f60fd251c13c37c27a73b8ff4315433a8e/regex-2024.11.6-cp311-cp311-win_amd64.whl", hash = "sha256:02e28184be537f0e75c1f9b2f8847dc51e08e6e171c6bde130b2687e0c33cf60", size = 274052, upload-time = "2024-11-06T20:10:05.179Z" }, - { url = "https://files.pythonhosted.org/packages/ba/30/9a87ce8336b172cc232a0db89a3af97929d06c11ceaa19d97d84fa90a8f8/regex-2024.11.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:52fb28f528778f184f870b7cf8f225f5eef0a8f6e3778529bdd40c7b3920796a", size = 483781, upload-time = "2024-11-06T20:10:07.07Z" }, - { url = "https://files.pythonhosted.org/packages/01/e8/00008ad4ff4be8b1844786ba6636035f7ef926db5686e4c0f98093612add/regex-2024.11.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdd6028445d2460f33136c55eeb1f601ab06d74cb3347132e1c24250187500d9", size = 288455, upload-time = "2024-11-06T20:10:09.117Z" }, - { url = "https://files.pythonhosted.org/packages/60/85/cebcc0aff603ea0a201667b203f13ba75d9fc8668fab917ac5b2de3967bc/regex-2024.11.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:805e6b60c54bf766b251e94526ebad60b7de0c70f70a4e6210ee2891acb70bf2", size = 284759, upload-time = "2024-11-06T20:10:11.155Z" }, - { url = "https://files.pythonhosted.org/packages/94/2b/701a4b0585cb05472a4da28ee28fdfe155f3638f5e1ec92306d924e5faf0/regex-2024.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b85c2530be953a890eaffde05485238f07029600e8f098cdf1848d414a8b45e4", size = 794976, upload-time = "2024-11-06T20:10:13.24Z" }, - { url = "https://files.pythonhosted.org/packages/4b/bf/fa87e563bf5fee75db8915f7352e1887b1249126a1be4813837f5dbec965/regex-2024.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb26437975da7dc36b7efad18aa9dd4ea569d2357ae6b783bf1118dabd9ea577", size = 833077, upload-time = "2024-11-06T20:10:15.37Z" }, - { url = "https://files.pythonhosted.org/packages/a1/56/7295e6bad94b047f4d0834e4779491b81216583c00c288252ef625c01d23/regex-2024.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abfa5080c374a76a251ba60683242bc17eeb2c9818d0d30117b4486be10c59d3", size = 823160, upload-time = "2024-11-06T20:10:19.027Z" }, - { url = "https://files.pythonhosted.org/packages/fb/13/e3b075031a738c9598c51cfbc4c7879e26729c53aa9cca59211c44235314/regex-2024.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b7fa6606c2881c1db9479b0eaa11ed5dfa11c8d60a474ff0e095099f39d98e", size = 796896, upload-time = "2024-11-06T20:10:21.85Z" }, - { url = "https://files.pythonhosted.org/packages/24/56/0b3f1b66d592be6efec23a795b37732682520b47c53da5a32c33ed7d84e3/regex-2024.11.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c32f75920cf99fe6b6c539c399a4a128452eaf1af27f39bce8909c9a3fd8cbe", size = 783997, upload-time = "2024-11-06T20:10:24.329Z" }, - { url = "https://files.pythonhosted.org/packages/f9/a1/eb378dada8b91c0e4c5f08ffb56f25fcae47bf52ad18f9b2f33b83e6d498/regex-2024.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:982e6d21414e78e1f51cf595d7f321dcd14de1f2881c5dc6a6e23bbbbd68435e", size = 781725, upload-time = "2024-11-06T20:10:28.067Z" }, - { url = "https://files.pythonhosted.org/packages/83/f2/033e7dec0cfd6dda93390089864732a3409246ffe8b042e9554afa9bff4e/regex-2024.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a7c2155f790e2fb448faed6dd241386719802296ec588a8b9051c1f5c481bc29", size = 789481, upload-time = "2024-11-06T20:10:31.612Z" }, - { url = "https://files.pythonhosted.org/packages/83/23/15d4552ea28990a74e7696780c438aadd73a20318c47e527b47a4a5a596d/regex-2024.11.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:149f5008d286636e48cd0b1dd65018548944e495b0265b45e1bffecce1ef7f39", size = 852896, upload-time = "2024-11-06T20:10:34.054Z" }, - { url = "https://files.pythonhosted.org/packages/e3/39/ed4416bc90deedbfdada2568b2cb0bc1fdb98efe11f5378d9892b2a88f8f/regex-2024.11.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e5364a4502efca094731680e80009632ad6624084aff9a23ce8c8c6820de3e51", size = 860138, upload-time = "2024-11-06T20:10:36.142Z" }, - { url = "https://files.pythonhosted.org/packages/93/2d/dd56bb76bd8e95bbce684326302f287455b56242a4f9c61f1bc76e28360e/regex-2024.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0a86e7eeca091c09e021db8eb72d54751e527fa47b8d5787caf96d9831bd02ad", size = 787692, upload-time = "2024-11-06T20:10:38.394Z" }, - { url = "https://files.pythonhosted.org/packages/0b/55/31877a249ab7a5156758246b9c59539abbeba22461b7d8adc9e8475ff73e/regex-2024.11.6-cp312-cp312-win32.whl", hash = "sha256:32f9a4c643baad4efa81d549c2aadefaeba12249b2adc5af541759237eee1c54", size = 262135, upload-time = "2024-11-06T20:10:40.367Z" }, - { url = "https://files.pythonhosted.org/packages/38/ec/ad2d7de49a600cdb8dd78434a1aeffe28b9d6fc42eb36afab4a27ad23384/regex-2024.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:a93c194e2df18f7d264092dc8539b8ffb86b45b899ab976aa15d48214138e81b", size = 273567, upload-time = "2024-11-06T20:10:43.467Z" }, - { url = "https://files.pythonhosted.org/packages/90/73/bcb0e36614601016552fa9344544a3a2ae1809dc1401b100eab02e772e1f/regex-2024.11.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a6ba92c0bcdf96cbf43a12c717eae4bc98325ca3730f6b130ffa2e3c3c723d84", size = 483525, upload-time = "2024-11-06T20:10:45.19Z" }, - { url = "https://files.pythonhosted.org/packages/0f/3f/f1a082a46b31e25291d830b369b6b0c5576a6f7fb89d3053a354c24b8a83/regex-2024.11.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:525eab0b789891ac3be914d36893bdf972d483fe66551f79d3e27146191a37d4", size = 288324, upload-time = "2024-11-06T20:10:47.177Z" }, - { url = "https://files.pythonhosted.org/packages/09/c9/4e68181a4a652fb3ef5099e077faf4fd2a694ea6e0f806a7737aff9e758a/regex-2024.11.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:086a27a0b4ca227941700e0b31425e7a28ef1ae8e5e05a33826e17e47fbfdba0", size = 284617, upload-time = "2024-11-06T20:10:49.312Z" }, - { url = "https://files.pythonhosted.org/packages/fc/fd/37868b75eaf63843165f1d2122ca6cb94bfc0271e4428cf58c0616786dce/regex-2024.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bde01f35767c4a7899b7eb6e823b125a64de314a8ee9791367c9a34d56af18d0", size = 795023, upload-time = "2024-11-06T20:10:51.102Z" }, - { url = "https://files.pythonhosted.org/packages/c4/7c/d4cd9c528502a3dedb5c13c146e7a7a539a3853dc20209c8e75d9ba9d1b2/regex-2024.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b583904576650166b3d920d2bcce13971f6f9e9a396c673187f49811b2769dc7", size = 833072, upload-time = "2024-11-06T20:10:52.926Z" }, - { url = "https://files.pythonhosted.org/packages/4f/db/46f563a08f969159c5a0f0e722260568425363bea43bb7ae370becb66a67/regex-2024.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c4de13f06a0d54fa0d5ab1b7138bfa0d883220965a29616e3ea61b35d5f5fc7", size = 823130, upload-time = "2024-11-06T20:10:54.828Z" }, - { url = "https://files.pythonhosted.org/packages/db/60/1eeca2074f5b87df394fccaa432ae3fc06c9c9bfa97c5051aed70e6e00c2/regex-2024.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cde6e9f2580eb1665965ce9bf17ff4952f34f5b126beb509fee8f4e994f143c", size = 796857, upload-time = "2024-11-06T20:10:56.634Z" }, - { url = "https://files.pythonhosted.org/packages/10/db/ac718a08fcee981554d2f7bb8402f1faa7e868c1345c16ab1ebec54b0d7b/regex-2024.11.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d7f453dca13f40a02b79636a339c5b62b670141e63efd511d3f8f73fba162b3", size = 784006, upload-time = "2024-11-06T20:10:59.369Z" }, - { url = "https://files.pythonhosted.org/packages/c2/41/7da3fe70216cea93144bf12da2b87367590bcf07db97604edeea55dac9ad/regex-2024.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59dfe1ed21aea057a65c6b586afd2a945de04fc7db3de0a6e3ed5397ad491b07", size = 781650, upload-time = "2024-11-06T20:11:02.042Z" }, - { url = "https://files.pythonhosted.org/packages/a7/d5/880921ee4eec393a4752e6ab9f0fe28009435417c3102fc413f3fe81c4e5/regex-2024.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b97c1e0bd37c5cd7902e65f410779d39eeda155800b65fc4d04cc432efa9bc6e", size = 789545, upload-time = "2024-11-06T20:11:03.933Z" }, - { url = "https://files.pythonhosted.org/packages/dc/96/53770115e507081122beca8899ab7f5ae28ae790bfcc82b5e38976df6a77/regex-2024.11.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f9d1e379028e0fc2ae3654bac3cbbef81bf3fd571272a42d56c24007979bafb6", size = 853045, upload-time = "2024-11-06T20:11:06.497Z" }, - { url = "https://files.pythonhosted.org/packages/31/d3/1372add5251cc2d44b451bd94f43b2ec78e15a6e82bff6a290ef9fd8f00a/regex-2024.11.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:13291b39131e2d002a7940fb176e120bec5145f3aeb7621be6534e46251912c4", size = 860182, upload-time = "2024-11-06T20:11:09.06Z" }, - { url = "https://files.pythonhosted.org/packages/ed/e3/c446a64984ea9f69982ba1a69d4658d5014bc7a0ea468a07e1a1265db6e2/regex-2024.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f51f88c126370dcec4908576c5a627220da6c09d0bff31cfa89f2523843316d", size = 787733, upload-time = "2024-11-06T20:11:11.256Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f1/e40c8373e3480e4f29f2692bd21b3e05f296d3afebc7e5dcf21b9756ca1c/regex-2024.11.6-cp313-cp313-win32.whl", hash = "sha256:63b13cfd72e9601125027202cad74995ab26921d8cd935c25f09c630436348ff", size = 262122, upload-time = "2024-11-06T20:11:13.161Z" }, - { url = "https://files.pythonhosted.org/packages/45/94/bc295babb3062a731f52621cdc992d123111282e291abaf23faa413443ea/regex-2024.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:2b3361af3198667e99927da8b84c1b010752fa4b1115ee30beaa332cabc3ef1a", size = 273545, upload-time = "2024-11-06T20:11:15Z" }, +version = "2025.7.34" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/de/e13fa6dc61d78b30ba47481f99933a3b49a57779d625c392d8036770a60d/regex-2025.7.34.tar.gz", hash = "sha256:9ead9765217afd04a86822dfcd4ed2747dfe426e887da413b15ff0ac2457e21a", size = 400714, upload-time = "2025-07-31T00:21:16.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/85/f497b91577169472f7c1dc262a5ecc65e39e146fc3a52c571e5daaae4b7d/regex-2025.7.34-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:da304313761b8500b8e175eb2040c4394a875837d5635f6256d6fa0377ad32c8", size = 484594, upload-time = "2025-07-31T00:19:13.927Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c5/ad2a5c11ce9e6257fcbfd6cd965d07502f6054aaa19d50a3d7fd991ec5d1/regex-2025.7.34-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:35e43ebf5b18cd751ea81455b19acfdec402e82fe0dc6143edfae4c5c4b3909a", size = 289294, upload-time = "2025-07-31T00:19:15.395Z" }, + { url = "https://files.pythonhosted.org/packages/8e/01/83ffd9641fcf5e018f9b51aa922c3e538ac9439424fda3df540b643ecf4f/regex-2025.7.34-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96bbae4c616726f4661fe7bcad5952e10d25d3c51ddc388189d8864fbc1b3c68", size = 285933, upload-time = "2025-07-31T00:19:16.704Z" }, + { url = "https://files.pythonhosted.org/packages/77/20/5edab2e5766f0259bc1da7381b07ce6eb4401b17b2254d02f492cd8a81a8/regex-2025.7.34-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9feab78a1ffa4f2b1e27b1bcdaad36f48c2fed4870264ce32f52a393db093c78", size = 792335, upload-time = "2025-07-31T00:19:18.561Z" }, + { url = "https://files.pythonhosted.org/packages/30/bd/744d3ed8777dce8487b2606b94925e207e7c5931d5870f47f5b643a4580a/regex-2025.7.34-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f14b36e6d4d07f1a5060f28ef3b3561c5d95eb0651741474ce4c0a4c56ba8719", size = 858605, upload-time = "2025-07-31T00:19:20.204Z" }, + { url = "https://files.pythonhosted.org/packages/99/3d/93754176289718d7578c31d151047e7b8acc7a8c20e7706716f23c49e45e/regex-2025.7.34-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85c3a958ef8b3d5079c763477e1f09e89d13ad22198a37e9d7b26b4b17438b33", size = 905780, upload-time = "2025-07-31T00:19:21.876Z" }, + { url = "https://files.pythonhosted.org/packages/ee/2e/c689f274a92deffa03999a430505ff2aeace408fd681a90eafa92fdd6930/regex-2025.7.34-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37555e4ae0b93358fa7c2d240a4291d4a4227cc7c607d8f85596cdb08ec0a083", size = 798868, upload-time = "2025-07-31T00:19:23.222Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9e/39673688805d139b33b4a24851a71b9978d61915c4d72b5ffda324d0668a/regex-2025.7.34-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee38926f31f1aa61b0232a3a11b83461f7807661c062df9eb88769d86e6195c3", size = 781784, upload-time = "2025-07-31T00:19:24.59Z" }, + { url = "https://files.pythonhosted.org/packages/18/bd/4c1cab12cfabe14beaa076523056b8ab0c882a8feaf0a6f48b0a75dab9ed/regex-2025.7.34-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a664291c31cae9c4a30589bd8bc2ebb56ef880c9c6264cb7643633831e606a4d", size = 852837, upload-time = "2025-07-31T00:19:25.911Z" }, + { url = "https://files.pythonhosted.org/packages/cb/21/663d983cbb3bba537fc213a579abbd0f263fb28271c514123f3c547ab917/regex-2025.7.34-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f3e5c1e0925e77ec46ddc736b756a6da50d4df4ee3f69536ffb2373460e2dafd", size = 844240, upload-time = "2025-07-31T00:19:27.688Z" }, + { url = "https://files.pythonhosted.org/packages/8e/2d/9beeeb913bc5d32faa913cf8c47e968da936af61ec20af5d269d0f84a100/regex-2025.7.34-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d428fc7731dcbb4e2ffe43aeb8f90775ad155e7db4347a639768bc6cd2df881a", size = 787139, upload-time = "2025-07-31T00:19:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f5/9b9384415fdc533551be2ba805dd8c4621873e5df69c958f403bfd3b2b6e/regex-2025.7.34-cp311-cp311-win32.whl", hash = "sha256:e154a7ee7fa18333ad90b20e16ef84daaeac61877c8ef942ec8dfa50dc38b7a1", size = 264019, upload-time = "2025-07-31T00:19:31.129Z" }, + { url = "https://files.pythonhosted.org/packages/18/9d/e069ed94debcf4cc9626d652a48040b079ce34c7e4fb174f16874958d485/regex-2025.7.34-cp311-cp311-win_amd64.whl", hash = "sha256:24257953d5c1d6d3c129ab03414c07fc1a47833c9165d49b954190b2b7f21a1a", size = 276047, upload-time = "2025-07-31T00:19:32.497Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cf/3bafbe9d1fd1db77355e7fbbbf0d0cfb34501a8b8e334deca14f94c7b315/regex-2025.7.34-cp311-cp311-win_arm64.whl", hash = "sha256:3157aa512b9e606586900888cd469a444f9b898ecb7f8931996cb715f77477f0", size = 268362, upload-time = "2025-07-31T00:19:34.094Z" }, + { url = "https://files.pythonhosted.org/packages/ff/f0/31d62596c75a33f979317658e8d261574785c6cd8672c06741ce2e2e2070/regex-2025.7.34-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7f7211a746aced993bef487de69307a38c5ddd79257d7be83f7b202cb59ddb50", size = 485492, upload-time = "2025-07-31T00:19:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/d8/16/b818d223f1c9758c3434be89aa1a01aae798e0e0df36c1f143d1963dd1ee/regex-2025.7.34-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fb31080f2bd0681484b275461b202b5ad182f52c9ec606052020fe13eb13a72f", size = 290000, upload-time = "2025-07-31T00:19:37.175Z" }, + { url = "https://files.pythonhosted.org/packages/cd/70/69506d53397b4bd6954061bae75677ad34deb7f6ca3ba199660d6f728ff5/regex-2025.7.34-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0200a5150c4cf61e407038f4b4d5cdad13e86345dac29ff9dab3d75d905cf130", size = 286072, upload-time = "2025-07-31T00:19:38.612Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/536a216d5f66084fb577bb0543b5cb7de3272eb70a157f0c3a542f1c2551/regex-2025.7.34-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:739a74970e736df0773788377969c9fea3876c2fc13d0563f98e5503e5185f46", size = 797341, upload-time = "2025-07-31T00:19:40.119Z" }, + { url = "https://files.pythonhosted.org/packages/26/af/733f8168449e56e8f404bb807ea7189f59507cbea1b67a7bbcd92f8bf844/regex-2025.7.34-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4fef81b2f7ea6a2029161ed6dea9ae13834c28eb5a95b8771828194a026621e4", size = 862556, upload-time = "2025-07-31T00:19:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/19/dd/59c464d58c06c4f7d87de4ab1f590e430821345a40c5d345d449a636d15f/regex-2025.7.34-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea74cf81fe61a7e9d77989050d0089a927ab758c29dac4e8e1b6c06fccf3ebf0", size = 910762, upload-time = "2025-07-31T00:19:43Z" }, + { url = "https://files.pythonhosted.org/packages/37/a8/b05ccf33ceca0815a1e253693b2c86544932ebcc0049c16b0fbdf18b688b/regex-2025.7.34-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4636a7f3b65a5f340ed9ddf53585c42e3ff37101d383ed321bfe5660481744b", size = 801892, upload-time = "2025-07-31T00:19:44.645Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9a/b993cb2e634cc22810afd1652dba0cae156c40d4864285ff486c73cd1996/regex-2025.7.34-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cef962d7834437fe8d3da6f9bfc6f93f20f218266dcefec0560ed7765f5fe01", size = 786551, upload-time = "2025-07-31T00:19:46.127Z" }, + { url = "https://files.pythonhosted.org/packages/2d/79/7849d67910a0de4e26834b5bb816e028e35473f3d7ae563552ea04f58ca2/regex-2025.7.34-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:cbe1698e5b80298dbce8df4d8d1182279fbdaf1044e864cbc9d53c20e4a2be77", size = 856457, upload-time = "2025-07-31T00:19:47.562Z" }, + { url = "https://files.pythonhosted.org/packages/91/c6/de516bc082524b27e45cb4f54e28bd800c01efb26d15646a65b87b13a91e/regex-2025.7.34-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:32b9f9bcf0f605eb094b08e8da72e44badabb63dde6b83bd530580b488d1c6da", size = 848902, upload-time = "2025-07-31T00:19:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/22/519ff8ba15f732db099b126f039586bd372da6cd4efb810d5d66a5daeda1/regex-2025.7.34-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:524c868ba527eab4e8744a9287809579f54ae8c62fbf07d62aacd89f6026b282", size = 788038, upload-time = "2025-07-31T00:19:50.794Z" }, + { url = "https://files.pythonhosted.org/packages/3f/7d/aabb467d8f57d8149895d133c88eb809a1a6a0fe262c1d508eb9dfabb6f9/regex-2025.7.34-cp312-cp312-win32.whl", hash = "sha256:d600e58ee6d036081c89696d2bdd55d507498a7180df2e19945c6642fac59588", size = 264417, upload-time = "2025-07-31T00:19:52.292Z" }, + { url = "https://files.pythonhosted.org/packages/3b/39/bd922b55a4fc5ad5c13753274e5b536f5b06ec8eb9747675668491c7ab7a/regex-2025.7.34-cp312-cp312-win_amd64.whl", hash = "sha256:9a9ab52a466a9b4b91564437b36417b76033e8778e5af8f36be835d8cb370d62", size = 275387, upload-time = "2025-07-31T00:19:53.593Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/c61d2fdcecb754a40475a3d1ef9a000911d3e3fc75c096acf44b0dfb786a/regex-2025.7.34-cp312-cp312-win_arm64.whl", hash = "sha256:c83aec91af9c6fbf7c743274fd952272403ad9a9db05fe9bfc9df8d12b45f176", size = 268482, upload-time = "2025-07-31T00:19:55.183Z" }, + { url = "https://files.pythonhosted.org/packages/15/16/b709b2119975035169a25aa8e4940ca177b1a2e25e14f8d996d09130368e/regex-2025.7.34-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c3c9740a77aeef3f5e3aaab92403946a8d34437db930a0280e7e81ddcada61f5", size = 485334, upload-time = "2025-07-31T00:19:56.58Z" }, + { url = "https://files.pythonhosted.org/packages/94/a6/c09136046be0595f0331bc58a0e5f89c2d324cf734e0b0ec53cf4b12a636/regex-2025.7.34-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:69ed3bc611540f2ea70a4080f853741ec698be556b1df404599f8724690edbcd", size = 289942, upload-time = "2025-07-31T00:19:57.943Z" }, + { url = "https://files.pythonhosted.org/packages/36/91/08fc0fd0f40bdfb0e0df4134ee37cfb16e66a1044ac56d36911fd01c69d2/regex-2025.7.34-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d03c6f9dcd562c56527c42b8530aad93193e0b3254a588be1f2ed378cdfdea1b", size = 285991, upload-time = "2025-07-31T00:19:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/99dc8f6f756606f0c214d14c7b6c17270b6bbe26d5c1f05cde9dbb1c551f/regex-2025.7.34-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6164b1d99dee1dfad33f301f174d8139d4368a9fb50bf0a3603b2eaf579963ad", size = 797415, upload-time = "2025-07-31T00:20:01.668Z" }, + { url = "https://files.pythonhosted.org/packages/62/cf/2fcdca1110495458ba4e95c52ce73b361cf1cafd8a53b5c31542cde9a15b/regex-2025.7.34-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1e4f4f62599b8142362f164ce776f19d79bdd21273e86920a7b604a4275b4f59", size = 862487, upload-time = "2025-07-31T00:20:03.142Z" }, + { url = "https://files.pythonhosted.org/packages/90/38/899105dd27fed394e3fae45607c1983e138273ec167e47882fc401f112b9/regex-2025.7.34-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:72a26dcc6a59c057b292f39d41465d8233a10fd69121fa24f8f43ec6294e5415", size = 910717, upload-time = "2025-07-31T00:20:04.727Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f6/4716198dbd0bcc9c45625ac4c81a435d1c4d8ad662e8576dac06bab35b17/regex-2025.7.34-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5273fddf7a3e602695c92716c420c377599ed3c853ea669c1fe26218867002f", size = 801943, upload-time = "2025-07-31T00:20:07.1Z" }, + { url = "https://files.pythonhosted.org/packages/40/5d/cff8896d27e4e3dd11dd72ac78797c7987eb50fe4debc2c0f2f1682eb06d/regex-2025.7.34-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c1844be23cd40135b3a5a4dd298e1e0c0cb36757364dd6cdc6025770363e06c1", size = 786664, upload-time = "2025-07-31T00:20:08.818Z" }, + { url = "https://files.pythonhosted.org/packages/10/29/758bf83cf7b4c34f07ac3423ea03cee3eb3176941641e4ccc05620f6c0b8/regex-2025.7.34-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dde35e2afbbe2272f8abee3b9fe6772d9b5a07d82607b5788e8508974059925c", size = 856457, upload-time = "2025-07-31T00:20:10.328Z" }, + { url = "https://files.pythonhosted.org/packages/d7/30/c19d212b619963c5b460bfed0ea69a092c6a43cba52a973d46c27b3e2975/regex-2025.7.34-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f6e8e7af516a7549412ce57613e859c3be27d55341a894aacaa11703a4c31a", size = 849008, upload-time = "2025-07-31T00:20:11.823Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b8/3c35da3b12c87e3cc00010ef6c3a4ae787cff0bc381aa3d251def219969a/regex-2025.7.34-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:469142fb94a869beb25b5f18ea87646d21def10fbacb0bcb749224f3509476f0", size = 788101, upload-time = "2025-07-31T00:20:13.729Z" }, + { url = "https://files.pythonhosted.org/packages/47/80/2f46677c0b3c2b723b2c358d19f9346e714113865da0f5f736ca1a883bde/regex-2025.7.34-cp313-cp313-win32.whl", hash = "sha256:da7507d083ee33ccea1310447410c27ca11fb9ef18c95899ca57ff60a7e4d8f1", size = 264401, upload-time = "2025-07-31T00:20:15.233Z" }, + { url = "https://files.pythonhosted.org/packages/be/fa/917d64dd074682606a003cba33585c28138c77d848ef72fc77cbb1183849/regex-2025.7.34-cp313-cp313-win_amd64.whl", hash = "sha256:9d644de5520441e5f7e2db63aec2748948cc39ed4d7a87fd5db578ea4043d997", size = 275368, upload-time = "2025-07-31T00:20:16.711Z" }, + { url = "https://files.pythonhosted.org/packages/65/cd/f94383666704170a2154a5df7b16be28f0c27a266bffcd843e58bc84120f/regex-2025.7.34-cp313-cp313-win_arm64.whl", hash = "sha256:7bf1c5503a9f2cbd2f52d7e260acb3131b07b6273c470abb78568174fe6bde3f", size = 268482, upload-time = "2025-07-31T00:20:18.189Z" }, + { url = "https://files.pythonhosted.org/packages/ac/23/6376f3a23cf2f3c00514b1cdd8c990afb4dfbac3cb4a68b633c6b7e2e307/regex-2025.7.34-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:8283afe7042d8270cecf27cca558873168e771183d4d593e3c5fe5f12402212a", size = 485385, upload-time = "2025-07-31T00:20:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/73/5b/6d4d3a0b4d312adbfd6d5694c8dddcf1396708976dd87e4d00af439d962b/regex-2025.7.34-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6c053f9647e3421dd2f5dff8172eb7b4eec129df9d1d2f7133a4386319b47435", size = 289788, upload-time = "2025-07-31T00:20:21.941Z" }, + { url = "https://files.pythonhosted.org/packages/92/71/5862ac9913746e5054d01cb9fb8125b3d0802c0706ef547cae1e7f4428fa/regex-2025.7.34-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a16dd56bbcb7d10e62861c3cd000290ddff28ea142ffb5eb3470f183628011ac", size = 286136, upload-time = "2025-07-31T00:20:26.146Z" }, + { url = "https://files.pythonhosted.org/packages/27/df/5b505dc447eb71278eba10d5ec940769ca89c1af70f0468bfbcb98035dc2/regex-2025.7.34-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69c593ff5a24c0d5c1112b0df9b09eae42b33c014bdca7022d6523b210b69f72", size = 797753, upload-time = "2025-07-31T00:20:27.919Z" }, + { url = "https://files.pythonhosted.org/packages/86/38/3e3dc953d13998fa047e9a2414b556201dbd7147034fbac129392363253b/regex-2025.7.34-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98d0ce170fcde1a03b5df19c5650db22ab58af375aaa6ff07978a85c9f250f0e", size = 863263, upload-time = "2025-07-31T00:20:29.803Z" }, + { url = "https://files.pythonhosted.org/packages/68/e5/3ff66b29dde12f5b874dda2d9dec7245c2051f2528d8c2a797901497f140/regex-2025.7.34-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d72765a4bff8c43711d5b0f5b452991a9947853dfa471972169b3cc0ba1d0751", size = 910103, upload-time = "2025-07-31T00:20:31.313Z" }, + { url = "https://files.pythonhosted.org/packages/9e/fe/14176f2182125977fba3711adea73f472a11f3f9288c1317c59cd16ad5e6/regex-2025.7.34-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4494f8fd95a77eb434039ad8460e64d57baa0434f1395b7da44015bef650d0e4", size = 801709, upload-time = "2025-07-31T00:20:33.323Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0d/80d4e66ed24f1ba876a9e8e31b709f9fd22d5c266bf5f3ab3c1afe683d7d/regex-2025.7.34-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4f42b522259c66e918a0121a12429b2abcf696c6f967fa37bdc7b72e61469f98", size = 786726, upload-time = "2025-07-31T00:20:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/c3ebb30e04a56c046f5c85179dc173818551037daae2c0c940c7b19152cb/regex-2025.7.34-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:aaef1f056d96a0a5d53ad47d019d5b4c66fe4be2da87016e0d43b7242599ffc7", size = 857306, upload-time = "2025-07-31T00:20:37.12Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b2/a4dc5d8b14f90924f27f0ac4c4c4f5e195b723be98adecc884f6716614b6/regex-2025.7.34-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:656433e5b7dccc9bc0da6312da8eb897b81f5e560321ec413500e5367fcd5d47", size = 848494, upload-time = "2025-07-31T00:20:38.818Z" }, + { url = "https://files.pythonhosted.org/packages/0d/21/9ac6e07a4c5e8646a90b56b61f7e9dac11ae0747c857f91d3d2bc7c241d9/regex-2025.7.34-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e91eb2c62c39705e17b4d42d4b86c4e86c884c0d15d9c5a47d0835f8387add8e", size = 787850, upload-time = "2025-07-31T00:20:40.478Z" }, + { url = "https://files.pythonhosted.org/packages/be/6c/d51204e28e7bc54f9a03bb799b04730d7e54ff2718862b8d4e09e7110a6a/regex-2025.7.34-cp314-cp314-win32.whl", hash = "sha256:f978ddfb6216028c8f1d6b0f7ef779949498b64117fc35a939022f67f810bdcb", size = 269730, upload-time = "2025-07-31T00:20:42.253Z" }, + { url = "https://files.pythonhosted.org/packages/74/52/a7e92d02fa1fdef59d113098cb9f02c5d03289a0e9f9e5d4d6acccd10677/regex-2025.7.34-cp314-cp314-win_amd64.whl", hash = "sha256:4b7dc33b9b48fb37ead12ffc7bdb846ac72f99a80373c4da48f64b373a7abeae", size = 278640, upload-time = "2025-07-31T00:20:44.42Z" }, + { url = "https://files.pythonhosted.org/packages/d1/78/a815529b559b1771080faa90c3ab401730661f99d495ab0071649f139ebd/regex-2025.7.34-cp314-cp314-win_arm64.whl", hash = "sha256:4b8c4d39f451e64809912c82392933d80fe2e4a87eeef8859fcc5380d0173c64", size = 271757, upload-time = "2025-07-31T00:20:46.355Z" }, ] [[package]] name = "requests" -version = "2.32.3" +version = "2.32.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -1639,48 +1785,118 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" }, + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] [[package]] name = "requests-sse" -version = "0.5.1" +version = "0.5.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/57/91c3be234a531164786622f74aed74610125cfa1a9e3b707df417479673e/requests_sse-0.5.1.tar.gz", hash = "sha256:42df8ad8b8428a44b3f27d2501b68d3f2dd6eaa8cf4cc82e9e53cc3d18eea9cd", size = 9235, upload-time = "2025-04-06T09:34:07.605Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/73/dd6b0ae667c7720ddd5479f6216b1442610fdd162e27ce7bfb8357083f06/requests_sse-0.5.2.tar.gz", hash = "sha256:2bcb7cf905074b18ff9f7322716234c1188dfde805bba38300b37c6b5ae3a20a", size = 9001, upload-time = "2025-06-17T01:32:42.768Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/98/3af56b760a81649034996ec11e9c59500f1e775d6f3296bfd992112dc08f/requests_sse-0.5.1-py3-none-any.whl", hash = "sha256:29165526fc138698f6fa4e88a8613ca8e1bd9b4b82f5d3220c60724cec60da96", size = 9911, upload-time = "2025-04-06T09:34:05.448Z" }, + { url = "https://files.pythonhosted.org/packages/15/73/bf4771da460b528edc0ff9f2845682b50d60ffb84b4587f90ef665408195/requests_sse-0.5.2-py3-none-any.whl", hash = "sha256:ebd9da245c2bb02bc070617e16b37a260a7386abf6cd9b2a250a6529a92c74cf", size = 10078, upload-time = "2025-06-17T01:32:41.299Z" }, ] [[package]] name = "rich" -version = "13.9.4" +version = "14.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149, upload-time = "2024-11-01T16:43:57.873Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424, upload-time = "2024-11-01T16:43:55.817Z" }, + { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" }, ] [[package]] name = "rich-toolkit" -version = "0.13.2" +version = "0.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/8a/71cfbf6bf6257ea785d1f030c22468f763eea1b3e5417620f2ba9abd6dca/rich_toolkit-0.13.2.tar.gz", hash = "sha256:fea92557530de7c28f121cbed572ad93d9e0ddc60c3ca643f1b831f2f56b95d3", size = 72288, upload-time = "2025-01-13T19:30:02.403Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/36/cdb3d51371ad0cccbf1541506304783bd72d55790709b8eb68c0d401a13a/rich_toolkit-0.15.0.tar.gz", hash = "sha256:3f5730e9f2d36d0bfe01cf723948b7ecf4cc355d2b71e2c00e094f7963128c09", size = 115118, upload-time = "2025-08-11T10:55:37.909Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/1b/1c2f43af46456050b27810a7a013af8a7e12bc545a0cdc00eb0df55eb769/rich_toolkit-0.13.2-py3-none-any.whl", hash = "sha256:f3f6c583e5283298a2f7dbd3c65aca18b7f818ad96174113ab5bec0b0e35ed61", size = 13566, upload-time = "2025-01-13T19:29:59.795Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/b0794eefb3cf78566b15e5bf576492c1d4a92ce5f6da55675bc11e9ef5d8/rich_toolkit-0.15.0-py3-none-any.whl", hash = "sha256:ddb91008283d4a7989fd8ff0324a48773a7a2276229c6a3070755645538ef1bb", size = 29062, upload-time = "2025-08-11T10:55:37.152Z" }, +] + +[[package]] +name = "rignore" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/46/05a94dc55ac03cf931d18e43b86ecee5ee054cb88b7853fffd741e35009c/rignore-0.6.4.tar.gz", hash = "sha256:e893fdd2d7fdcfa9407d0b7600ef2c2e2df97f55e1c45d4a8f54364829ddb0ab", size = 11633, upload-time = "2025-07-19T19:24:46.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/11/66992d271dbc44eac33f3b6b871855bc17e511b9279a2a0982b44c2b0c01/rignore-0.6.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:85f684dfc2c497e35ad34ffd6744a3bcdcac273ec1dbe7d0464bfa20f3331434", size = 888239, upload-time = "2025-07-19T19:23:51.835Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1b/a9bde714e474043f97a06097925cf11e4597f9453adc267427d05ff9f38e/rignore-0.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23954acc6debc852dbccbffbb70f0e26b12d230239e1ad0638eb5540694d0308", size = 824348, upload-time = "2025-07-19T19:23:45.54Z" }, + { url = "https://files.pythonhosted.org/packages/db/58/dabba227fee6553f9be069f58128419b6d4954c784c4cd566cfe59955c1f/rignore-0.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2bf793bd58dbf3dee063a758b23ea446b5f037370405ecefc78e1e8923fc658", size = 892419, upload-time = "2025-07-19T19:22:33.763Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/e3c16368ee32d6d1146cf219b127fd5c7e6baf22cad7a7a5967782ff3b20/rignore-0.6.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1eaeaa5a904e098604ea2012383a721de06211c8b4013abf0d41c3cfeb982f4f", size = 873285, upload-time = "2025-07-19T19:22:46.67Z" }, + { url = "https://files.pythonhosted.org/packages/78/9d/ef43d760dc3d18011d8482692b478785a846bba64157844b3068e428739c/rignore-0.6.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a48bdbeb03093e3fac2b40d62a718c59b5bb4f29cfdc8e7cbb360e1ea7bf0056", size = 1160457, upload-time = "2025-07-19T19:22:59.457Z" }, + { url = "https://files.pythonhosted.org/packages/95/de/eca1b035705e0b4e6c630fd1fcec45d14cf354a4acea88cf29ea0a322fea/rignore-0.6.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8c5f9452d116be405f0967160b449c46ac929b50eaf527f33ee4680e3716e39", size = 938833, upload-time = "2025-07-19T19:23:11.657Z" }, + { url = "https://files.pythonhosted.org/packages/d4/2d/58912efa4137e989616d679a5390b53e93d5150be47217dd686ff60cd4cd/rignore-0.6.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6cf1039bfbdaa0f9710a6fb75436c25ca26d364881ec4d1e66d466bb36a7fb98", size = 950603, upload-time = "2025-07-19T19:23:35.245Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3d/9827cc1c7674d8d884d3d231a224a2db8ea8eae075a1611dfdcd0c301e20/rignore-0.6.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:136629eb0ec2b6ac6ab34e71ce8065a07106fe615a53eceefc30200d528a4612", size = 976867, upload-time = "2025-07-19T19:23:24.919Z" }, + { url = "https://files.pythonhosted.org/packages/75/47/9dcee35e24897b62d66f7578f127bc91465c942a9d702d516d3fe7dcaa00/rignore-0.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:35e3d0ebaf01086e6454c3fecae141e2db74a5ddf4a97c72c69428baeff0b7d4", size = 1067603, upload-time = "2025-07-19T19:23:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/4b/68/f66e7c0b0fc009f3e19ba8e6c3078a227285e3aecd9f6498d39df808cdfd/rignore-0.6.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:7ed1f9010fa1ef5ea0b69803d1dfb4b7355921779e03a30396034c52691658bc", size = 1136289, upload-time = "2025-07-19T19:24:11.136Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b7/6fff161fe3ae5c0e0a0dded9a428e41d31c7fefc4e57c7553b9ffb064139/rignore-0.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c16e9e898ed0afe2e20fa8d6412e02bd13f039f7e0d964a289368efd4d9ad320", size = 1111566, upload-time = "2025-07-19T19:24:23.065Z" }, + { url = "https://files.pythonhosted.org/packages/1f/c5/a5978ad65074a08dad46233a3333d154ae9cb9339325f3c181002a174746/rignore-0.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7e6bc0bdcd404a7a8268629e8e99967127bb41e02d9eb09a471364c4bc25e215", size = 1121142, upload-time = "2025-07-19T19:24:35.151Z" }, + { url = "https://files.pythonhosted.org/packages/e8/af/91f084374b95dc2477a4bd066957beb3b61b551f2364b4f7f5bc52c9e4c7/rignore-0.6.4-cp311-cp311-win32.whl", hash = "sha256:fdd59bd63d2a49cc6d4f3598f285552ccb1a41e001df1012e0e0345cf2cabf79", size = 643031, upload-time = "2025-07-19T19:24:55.541Z" }, + { url = "https://files.pythonhosted.org/packages/07/3a/31672aa957aebba8903005313697127bbbad9db3afcfc9857150301fab1d/rignore-0.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:7bf5be0e8a01845e57b5faa47ef9c623bb2070aa2f743c2fc73321ffaae45701", size = 721003, upload-time = "2025-07-19T19:24:48.867Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6c/e5af4383cdd7829ef9aa63ac82a6507983e02dbc7c2e7b9aa64b7b8e2c7a/rignore-0.6.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:74720d074b79f32449d5d212ce732e0144a294a184246d1f1e7bcc1fc5c83b69", size = 885885, upload-time = "2025-07-19T19:23:53.236Z" }, + { url = "https://files.pythonhosted.org/packages/89/3e/1b02a868830e464769aa417ee195ac352fe71ff818df8ce50c4b998edb9c/rignore-0.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a8184fcf567bd6b6d7b85a0c138d98dd40f63054141c96b175844414c5530d7", size = 819736, upload-time = "2025-07-19T19:23:46.565Z" }, + { url = "https://files.pythonhosted.org/packages/e0/75/b9be0c523d97c09f3c6508a67ce376aba4efe41c333c58903a0d7366439a/rignore-0.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcb0d7d7ecc3fbccf6477bb187c04a091579ea139f15f139abe0b3b48bdfef69", size = 892779, upload-time = "2025-07-19T19:22:35.167Z" }, + { url = "https://files.pythonhosted.org/packages/91/f4/3064b06233697f2993485d132f06fe95061fef71631485da75aed246c4fd/rignore-0.6.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:feac73377a156fb77b3df626c76f7e5893d9b4e9e886ac8c0f9d44f1206a2a91", size = 872116, upload-time = "2025-07-19T19:22:47.828Z" }, + { url = "https://files.pythonhosted.org/packages/99/94/cb8e7af9a3c0a665f10e2366144e0ebc66167cf846aca5f1ac31b3661598/rignore-0.6.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:465179bc30beb1f7a3439e428739a2b5777ed26660712b8c4e351b15a7c04483", size = 1163345, upload-time = "2025-07-19T19:23:00.557Z" }, + { url = "https://files.pythonhosted.org/packages/86/6b/49faa7ad85ceb6ccef265df40091d9992232d7f6055fa664fe0a8b13781c/rignore-0.6.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4a4877b4dca9cf31a4d09845b300c677c86267657540d0b4d3e6d0ce3110e6e9", size = 939967, upload-time = "2025-07-19T19:23:13.494Z" }, + { url = "https://files.pythonhosted.org/packages/80/c8/b91afda10bd5ca1e3a80463340b899c0dc26a7750a9f3c94f668585c7f40/rignore-0.6.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:456456802b1e77d1e2d149320ee32505b8183e309e228129950b807d204ddd17", size = 949717, upload-time = "2025-07-19T19:23:36.404Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f1/88bfdde58ae3fb1c1a92bb801f492eea8eafcdaf05ab9b75130023a4670b/rignore-0.6.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c1ff2fc223f1d9473d36923160af37bf765548578eb9d47a2f52e90da8ae408", size = 975534, upload-time = "2025-07-19T19:23:25.988Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8f/a80b4a2e48ceba56ba19e096d41263d844757e10aa36ede212571b5d8117/rignore-0.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e445fbc214ae18e0e644a78086ea5d0f579e210229a4fbe86367d11a4cd03c11", size = 1067837, upload-time = "2025-07-19T19:23:59.888Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/0905597af0e78748909ef58418442a480ddd93e9fc89b0ca9ab170c357c0/rignore-0.6.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e07d9c5270fc869bc431aadcfb6ed0447f89b8aafaa666914c077435dc76a123", size = 1134959, upload-time = "2025-07-19T19:24:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/cc/7d/0fa29adf9183b61947ce6dc8a1a9779a8ea16573f557be28ec893f6ddbaa/rignore-0.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7a6ccc0ea83d2c0c6df6b166f2acacedcc220a516436490f41e99a5ae73b6019", size = 1109708, upload-time = "2025-07-19T19:24:24.176Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a7/92892ed86b2e36da403dd3a0187829f2d880414cef75bd612bfdf4dedebc/rignore-0.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:536392c5ec91755db48389546c833c4ab1426fe03e5a8522992b54ef8a244e7e", size = 1120546, upload-time = "2025-07-19T19:24:36.377Z" }, + { url = "https://files.pythonhosted.org/packages/31/1b/d29ae1fe901d523741d6d1d3ffe0d630734dd0ed6b047628a69c1e15ea44/rignore-0.6.4-cp312-cp312-win32.whl", hash = "sha256:f5f9dca46fc41c0a1e236767f68be9d63bdd2726db13a0ae3a30f68414472969", size = 642005, upload-time = "2025-07-19T19:24:56.671Z" }, + { url = "https://files.pythonhosted.org/packages/1a/41/a224944824688995374e4525115ce85fecd82442fc85edd5bcd81f4f256d/rignore-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:e02eecb9e1b9f9bf7c9030ae73308a777bed3b2486204cc74dfcfbe699ab1497", size = 720358, upload-time = "2025-07-19T19:24:49.959Z" }, + { url = "https://files.pythonhosted.org/packages/db/a3/edd7d0d5cc0720de132b6651cef95ee080ce5fca11c77d8a47db848e5f90/rignore-0.6.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2b3b1e266ce45189240d14dfa1057f8013ea34b9bc8b3b44125ec8d25fdb3985", size = 885304, upload-time = "2025-07-19T19:23:54.268Z" }, + { url = "https://files.pythonhosted.org/packages/93/a1/d8d2fb97a6548307507d049b7e93885d4a0dfa1c907af5983fd9f9362a21/rignore-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45fe803628cc14714df10e8d6cdc23950a47eb9eb37dfea9a4779f4c672d2aa0", size = 818799, upload-time = "2025-07-19T19:23:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/b1/cd/949981fcc180ad5ba7b31c52e78b74b2dea6b7bf744ad4c0c4b212f6da78/rignore-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e439f034277a947a4126e2da79dbb43e33d73d7c09d3d72a927e02f8a16f59aa", size = 892024, upload-time = "2025-07-19T19:22:36.18Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d3/9042d701a8062d9c88f87760bbc2695ee2c23b3f002d34486b72a85f8efe/rignore-0.6.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:84b5121650ae24621154c7bdba8b8970b0739d8146505c9f38e0cda9385d1004", size = 871430, upload-time = "2025-07-19T19:22:49.62Z" }, + { url = "https://files.pythonhosted.org/packages/eb/50/3370249b984212b7355f3d9241aa6d02e706067c6d194a2614dfbc0f5b27/rignore-0.6.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52b0957b585ab48a445cf8ac1dbc33a272ab060835e583b4f95aa8c67c23fb2b", size = 1160559, upload-time = "2025-07-19T19:23:01.629Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6f/2ad7f925838091d065524f30a8abda846d1813eee93328febf262b5cda21/rignore-0.6.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:50359e0d5287b5e2743bd2f2fbf05df619c8282fd3af12f6628ff97b9675551d", size = 939947, upload-time = "2025-07-19T19:23:14.608Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/626ec94d62475ae7ef8b00ef98cea61cbea52a389a666703c97c4673d406/rignore-0.6.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efe18096dcb1596757dfe0b412aab6d32564473ae7ee58dea0a8b4be5b1a2e3b", size = 949471, upload-time = "2025-07-19T19:23:37.521Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c3/699c4f03b3c46f4b5c02f17a0a339225da65aad547daa5b03001e7c6a382/rignore-0.6.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b79c212d9990a273ad91e8d9765e1766ef6ecedd3be65375d786a252762ba385", size = 974912, upload-time = "2025-07-19T19:23:27.13Z" }, + { url = "https://files.pythonhosted.org/packages/cd/35/04626c12f9f92a9fc789afc2be32838a5d9b23b6fa8b2ad4a8625638d15b/rignore-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c6ffa7f2a8894c65aa5dc4e8ac8bbdf39a326c0c6589efd27686cfbb48f0197d", size = 1067281, upload-time = "2025-07-19T19:24:01.016Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9c/8f17baf3b984afea151cb9094716f6f1fb8e8737db97fc6eb6d494bd0780/rignore-0.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a63f5720dffc8d8fb0a4d02fafb8370a4031ebf3f99a4e79f334a91e905b7349", size = 1134414, upload-time = "2025-07-19T19:24:13.534Z" }, + { url = "https://files.pythonhosted.org/packages/10/88/ef84ffa916a96437c12cefcc39d474122da9626d75e3a2ebe09ec5d32f1b/rignore-0.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ce33982da47ac5dc09d19b04fa8d7c9aa6292fc0bd1ecf33076989faa8886094", size = 1109330, upload-time = "2025-07-19T19:24:25.303Z" }, + { url = "https://files.pythonhosted.org/packages/27/43/2ada5a2ec03b82e903610a1c483f516f78e47700ee6db9823f739e08b3af/rignore-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d899621867aa266824fbd9150e298f19d25b93903ef0133c09f70c65a3416eca", size = 1120381, upload-time = "2025-07-19T19:24:37.798Z" }, + { url = "https://files.pythonhosted.org/packages/3b/99/e7bcc643085131cb14dbea772def72bf1f6fe9037171ebe177c4f228abc8/rignore-0.6.4-cp313-cp313-win32.whl", hash = "sha256:d0615a6bf4890ec5a90b5fb83666822088fbd4e8fcd740c386fcce51e2f6feea", size = 641761, upload-time = "2025-07-19T19:24:58.096Z" }, + { url = "https://files.pythonhosted.org/packages/d9/25/7798908044f27dea1a8abdc75c14523e33770137651e5f775a15143f4218/rignore-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:145177f0e32716dc2f220b07b3cde2385b994b7ea28d5c96fbec32639e9eac6f", size = 719876, upload-time = "2025-07-19T19:24:51.125Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e3/ae1e30b045bf004ad77bbd1679b9afff2be8edb166520921c6f29420516a/rignore-0.6.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e55bf8f9bbd186f58ab646b4a08718c77131d28a9004e477612b0cbbd5202db2", size = 891776, upload-time = "2025-07-19T19:22:37.78Z" }, + { url = "https://files.pythonhosted.org/packages/45/a9/1193e3bc23ca0e6eb4f17cf4b99971237f97cfa6f241d98366dff90a6d09/rignore-0.6.4-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2521f7bf3ee1f2ab22a100a3a4eed39a97b025804e5afe4323528e9ce8f084a5", size = 871442, upload-time = "2025-07-19T19:22:50.972Z" }, + { url = "https://files.pythonhosted.org/packages/20/83/4c52ae429a0b2e1ce667e35b480e9a6846f9468c443baeaed5d775af9485/rignore-0.6.4-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cc35773a8a9c119359ef974d0856988d4601d4daa6f532c05f66b4587cf35bc", size = 1159844, upload-time = "2025-07-19T19:23:02.751Z" }, + { url = "https://files.pythonhosted.org/packages/c1/2f/c740f5751f464c937bfe252dc15a024ae081352cfe80d94aa16d6a617482/rignore-0.6.4-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b665b1ea14457d7b49e834baabc635a3b8c10cfb5cca5c21161fabdbfc2b850e", size = 939456, upload-time = "2025-07-19T19:23:15.72Z" }, + { url = "https://files.pythonhosted.org/packages/fc/dd/68dbb08ac0edabf44dd144ff546a3fb0253c5af708e066847df39fc9188f/rignore-0.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c7fd339f344a8548724f289495b835bed7b81174a0bc1c28c6497854bd8855db", size = 1067070, upload-time = "2025-07-19T19:24:02.803Z" }, + { url = "https://files.pythonhosted.org/packages/3b/3a/7e7ea6f0d31d3f5beb0f2cf2c4c362672f5f7f125714458673fc579e2bed/rignore-0.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:91dc94b1cc5af8d6d25ce6edd29e7351830f19b0a03b75cb3adf1f76d00f3007", size = 1134598, upload-time = "2025-07-19T19:24:15.039Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/1b3307f6437d29bede5a95738aa89e6d910ba68d4054175c9f60d8e2c6b1/rignore-0.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:4d1918221a249e5342b60fd5fa513bf3d6bf272a8738e66023799f0c82ecd788", size = 1108862, upload-time = "2025-07-19T19:24:26.765Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d5/b37c82519f335f2c472a63fc6215c6f4c51063ecf3166e3acf508011afbd/rignore-0.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:240777332b859dc89dcba59ab6e3f1e062bc8e862ffa3e5f456e93f7fd5cb415", size = 1120002, upload-time = "2025-07-19T19:24:38.952Z" }, + { url = "https://files.pythonhosted.org/packages/ac/72/2f05559ed5e69bdfdb56ea3982b48e6c0017c59f7241f7e1c5cae992b347/rignore-0.6.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66b0e548753e55cc648f1e7b02d9f74285fe48bb49cec93643d31e563773ab3f", size = 949454, upload-time = "2025-07-19T19:23:38.664Z" }, + { url = "https://files.pythonhosted.org/packages/0b/92/186693c8f838d670510ac1dfb35afbe964320fbffb343ba18f3d24441941/rignore-0.6.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6971ac9fdd5a0bd299a181096f091c4f3fd286643adceba98eccc03c688a6637", size = 974663, upload-time = "2025-07-19T19:23:28.24Z" }, + { url = "https://files.pythonhosted.org/packages/33/a1/daaa2df10dfa6d87c896a5783c8407c284530d5a056307d1f55a8ef0c533/rignore-0.6.4-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b3da26d5a35ab15525b68d30b7352ad2247321f5201fc7e50ba6d547f78d5ea", size = 895772, upload-time = "2025-07-19T19:22:43.423Z" }, + { url = "https://files.pythonhosted.org/packages/35/e6/65130a50cd3ed11c967034dfd653e160abb7879fb4ee338a1cccaeda7acd/rignore-0.6.4-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43028f3587558231d9fa68accff58c901dc50fd7bbc5764d3ee3df95290f6ebf", size = 873093, upload-time = "2025-07-19T19:22:55.745Z" }, + { url = "https://files.pythonhosted.org/packages/32/c4/02ead1274ce935c59f2bb3deaaaa339df9194bc40e3c2d8d623e31e47ec4/rignore-0.6.4-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc56f1fcab7740751b98fead67b98ba64896424d8c834ea22089568db4e36dfa", size = 1162199, upload-time = "2025-07-19T19:23:08.376Z" }, + { url = "https://files.pythonhosted.org/packages/78/0c/94a4edce0e80af69f200cc35d8da4c727c52d28f0c9d819b388849ae8ef6/rignore-0.6.4-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6033f2280898535a5f69935e08830a4e49ff1e29ef2c3f9a2b9ced59de06fdbf", size = 940176, upload-time = "2025-07-19T19:23:20.862Z" }, + { url = "https://files.pythonhosted.org/packages/43/92/21ec579c999a3ed4d1b2a5926a9d0edced7c65d8ac353bc9120d49b05a64/rignore-0.6.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f5ac0c4e6a24be88f3821e101ef4665e9e1dc015f9e45109f32fed71dbcdafa", size = 951632, upload-time = "2025-07-19T19:23:43.32Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/72e7ba244222b9efdeb18f9974d6f1e30cf5a2289e1b482a1e8b3ebee90f/rignore-0.6.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8906ac8dd585ece83b1346e0470260a1951058cc0ef5a17542069bde4aa3f42f", size = 976923, upload-time = "2025-07-19T19:23:32.678Z" }, + { url = "https://files.pythonhosted.org/packages/8e/14/e754c12bc953c7fa309687cd30a6ea95e5721168fb0b2a99a34bff24be5c/rignore-0.6.4-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:14d095622969504a2e56f666286202dad583f08d3347b7be2d647ddfd7a9bf47", size = 1069861, upload-time = "2025-07-19T19:24:07.671Z" }, + { url = "https://files.pythonhosted.org/packages/a6/24/ba2bdaf04a19b5331c051b9d480e8daca832bed4aeaa156d6d679044c06c/rignore-0.6.4-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:30f3d688df7eb4850318f1b5864d14f2c5fe5dbf3803ed0fc8329d2a7ad560dc", size = 1136368, upload-time = "2025-07-19T19:24:19.68Z" }, + { url = "https://files.pythonhosted.org/packages/83/48/7cf52353299e02aa629150007fa75f4b91d99b4f2fa536f2e24ead810116/rignore-0.6.4-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:028f62a7b0a6235bb3f03c9e7f342352e7fa4b3f08c761c72f9de8faee40ed9c", size = 1111714, upload-time = "2025-07-19T19:24:31.717Z" }, + { url = "https://files.pythonhosted.org/packages/84/9c/3881ad34f01942af0cf713e25e476bf851e04e389cc3ff146c3b459ab861/rignore-0.6.4-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:7e6c425603db2c147eace4f752ca3cd4551e7568c9d332175d586c68bcbe3d8d", size = 1122433, upload-time = "2025-07-19T19:24:43.973Z" }, ] [[package]] @@ -1739,6 +1955,19 @@ dev = [ { name = "pytest-httpx", specifier = ">=0.30.0" }, ] +[[package]] +name = "sentry-sdk" +version = "2.35.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/83/055dc157b719651ef13db569bb8cf2103df11174478649735c1b2bf3f6bc/sentry_sdk-2.35.0.tar.gz", hash = "sha256:5ea58d352779ce45d17bc2fa71ec7185205295b83a9dbb5707273deb64720092", size = 343014, upload-time = "2025-08-14T17:11:20.223Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/3d/742617a7c644deb0c1628dcf6bb2d2165ab7c6aab56fe5222758994007f8/sentry_sdk-2.35.0-py2.py3-none-any.whl", hash = "sha256:6e0c29b9a5d34de8575ffb04d289a987ff3053cf2c98ede445bea995e3830263", size = 363806, upload-time = "2025-08-14T17:11:18.29Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -1768,44 +1997,44 @@ wheels = [ [[package]] name = "starlette" -version = "0.45.3" +version = "0.46.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/fb/2984a686808b89a6781526129a4b51266f678b2d2b97ab2d325e56116df8/starlette-0.45.3.tar.gz", hash = "sha256:2cbcba2a75806f8a41c722141486f37c28e30a0921c5f6fe4346cb0dcee1302f", size = 2574076, upload-time = "2025-01-24T11:17:36.535Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/61/f2b52e107b1fc8944b33ef56bf6ac4ebbe16d91b94d2b87ce013bf63fb84/starlette-0.45.3-py3-none-any.whl", hash = "sha256:dfb6d332576f136ec740296c7e8bb8c8a7125044e7c6da30744718880cdd059d", size = 71507, upload-time = "2025-01-24T11:17:34.182Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" }, ] [[package]] name = "tiktoken" -version = "0.9.0" +version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "regex" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ea/cf/756fedf6981e82897f2d570dd25fa597eb3f4459068ae0572d7e888cfd6f/tiktoken-0.9.0.tar.gz", hash = "sha256:d02a5ca6a938e0490e1ff957bc48c8b078c88cb83977be1625b1fd8aac792c5d", size = 35991, upload-time = "2025-02-14T06:03:01.003Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/ae/4613a59a2a48e761c5161237fc850eb470b4bb93696db89da51b79a871f1/tiktoken-0.9.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f32cc56168eac4851109e9b5d327637f15fd662aa30dd79f964b7c39fbadd26e", size = 1065987, upload-time = "2025-02-14T06:02:14.174Z" }, - { url = "https://files.pythonhosted.org/packages/3f/86/55d9d1f5b5a7e1164d0f1538a85529b5fcba2b105f92db3622e5d7de6522/tiktoken-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:45556bc41241e5294063508caf901bf92ba52d8ef9222023f83d2483a3055348", size = 1009155, upload-time = "2025-02-14T06:02:15.384Z" }, - { url = "https://files.pythonhosted.org/packages/03/58/01fb6240df083b7c1916d1dcb024e2b761213c95d576e9f780dfb5625a76/tiktoken-0.9.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03935988a91d6d3216e2ec7c645afbb3d870b37bcb67ada1943ec48678e7ee33", size = 1142898, upload-time = "2025-02-14T06:02:16.666Z" }, - { url = "https://files.pythonhosted.org/packages/b1/73/41591c525680cd460a6becf56c9b17468d3711b1df242c53d2c7b2183d16/tiktoken-0.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b3d80aad8d2c6b9238fc1a5524542087c52b860b10cbf952429ffb714bc1136", size = 1197535, upload-time = "2025-02-14T06:02:18.595Z" }, - { url = "https://files.pythonhosted.org/packages/7d/7c/1069f25521c8f01a1a182f362e5c8e0337907fae91b368b7da9c3e39b810/tiktoken-0.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b2a21133be05dc116b1d0372af051cd2c6aa1d2188250c9b553f9fa49301b336", size = 1259548, upload-time = "2025-02-14T06:02:20.729Z" }, - { url = "https://files.pythonhosted.org/packages/6f/07/c67ad1724b8e14e2b4c8cca04b15da158733ac60136879131db05dda7c30/tiktoken-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:11a20e67fdf58b0e2dea7b8654a288e481bb4fc0289d3ad21291f8d0849915fb", size = 893895, upload-time = "2025-02-14T06:02:22.67Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e5/21ff33ecfa2101c1bb0f9b6df750553bd873b7fb532ce2cb276ff40b197f/tiktoken-0.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e88f121c1c22b726649ce67c089b90ddda8b9662545a8aeb03cfef15967ddd03", size = 1065073, upload-time = "2025-02-14T06:02:24.768Z" }, - { url = "https://files.pythonhosted.org/packages/8e/03/a95e7b4863ee9ceec1c55983e4cc9558bcfd8f4f80e19c4f8a99642f697d/tiktoken-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a6600660f2f72369acb13a57fb3e212434ed38b045fd8cc6cdd74947b4b5d210", size = 1008075, upload-time = "2025-02-14T06:02:26.92Z" }, - { url = "https://files.pythonhosted.org/packages/40/10/1305bb02a561595088235a513ec73e50b32e74364fef4de519da69bc8010/tiktoken-0.9.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95e811743b5dfa74f4b227927ed86cbc57cad4df859cb3b643be797914e41794", size = 1140754, upload-time = "2025-02-14T06:02:28.124Z" }, - { url = "https://files.pythonhosted.org/packages/1b/40/da42522018ca496432ffd02793c3a72a739ac04c3794a4914570c9bb2925/tiktoken-0.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99376e1370d59bcf6935c933cb9ba64adc29033b7e73f5f7569f3aad86552b22", size = 1196678, upload-time = "2025-02-14T06:02:29.845Z" }, - { url = "https://files.pythonhosted.org/packages/5c/41/1e59dddaae270ba20187ceb8aa52c75b24ffc09f547233991d5fd822838b/tiktoken-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:badb947c32739fb6ddde173e14885fb3de4d32ab9d8c591cbd013c22b4c31dd2", size = 1259283, upload-time = "2025-02-14T06:02:33.838Z" }, - { url = "https://files.pythonhosted.org/packages/5b/64/b16003419a1d7728d0d8c0d56a4c24325e7b10a21a9dd1fc0f7115c02f0a/tiktoken-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:5a62d7a25225bafed786a524c1b9f0910a1128f4232615bf3f8257a73aaa3b16", size = 894897, upload-time = "2025-02-14T06:02:36.265Z" }, - { url = "https://files.pythonhosted.org/packages/7a/11/09d936d37f49f4f494ffe660af44acd2d99eb2429d60a57c71318af214e0/tiktoken-0.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b0e8e05a26eda1249e824156d537015480af7ae222ccb798e5234ae0285dbdb", size = 1064919, upload-time = "2025-02-14T06:02:37.494Z" }, - { url = "https://files.pythonhosted.org/packages/80/0e/f38ba35713edb8d4197ae602e80837d574244ced7fb1b6070b31c29816e0/tiktoken-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:27d457f096f87685195eea0165a1807fae87b97b2161fe8c9b1df5bd74ca6f63", size = 1007877, upload-time = "2025-02-14T06:02:39.516Z" }, - { url = "https://files.pythonhosted.org/packages/fe/82/9197f77421e2a01373e27a79dd36efdd99e6b4115746ecc553318ecafbf0/tiktoken-0.9.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cf8ded49cddf825390e36dd1ad35cd49589e8161fdcb52aa25f0583e90a3e01", size = 1140095, upload-time = "2025-02-14T06:02:41.791Z" }, - { url = "https://files.pythonhosted.org/packages/f2/bb/4513da71cac187383541facd0291c4572b03ec23c561de5811781bbd988f/tiktoken-0.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc156cb314119a8bb9748257a2eaebd5cc0753b6cb491d26694ed42fc7cb3139", size = 1195649, upload-time = "2025-02-14T06:02:43Z" }, - { url = "https://files.pythonhosted.org/packages/fa/5c/74e4c137530dd8504e97e3a41729b1103a4ac29036cbfd3250b11fd29451/tiktoken-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cd69372e8c9dd761f0ab873112aba55a0e3e506332dd9f7522ca466e817b1b7a", size = 1258465, upload-time = "2025-02-14T06:02:45.046Z" }, - { url = "https://files.pythonhosted.org/packages/de/a8/8f499c179ec900783ffe133e9aab10044481679bb9aad78436d239eee716/tiktoken-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5ea0edb6f83dc56d794723286215918c1cde03712cbbafa0348b33448faf5b95", size = 894669, upload-time = "2025-02-14T06:02:47.341Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/a7/86/ad0155a37c4f310935d5ac0b1ccf9bdb635dcb906e0a9a26b616dd55825a/tiktoken-0.11.0.tar.gz", hash = "sha256:3c518641aee1c52247c2b97e74d8d07d780092af79d5911a6ab5e79359d9b06a", size = 37648, upload-time = "2025-08-08T23:58:08.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/91/912b459799a025d2842566fe1e902f7f50d54a1ce8a0f236ab36b5bd5846/tiktoken-0.11.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4ae374c46afadad0f501046db3da1b36cd4dfbfa52af23c998773682446097cf", size = 1059743, upload-time = "2025-08-08T23:57:37.516Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/6faa6870489ce64f5f75dcf91512bf35af5864583aee8fcb0dcb593121f5/tiktoken-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:25a512ff25dc6c85b58f5dd4f3d8c674dc05f96b02d66cdacf628d26a4e4866b", size = 999334, upload-time = "2025-08-08T23:57:38.595Z" }, + { url = "https://files.pythonhosted.org/packages/a1/3e/a05d1547cf7db9dc75d1461cfa7b556a3b48e0516ec29dfc81d984a145f6/tiktoken-0.11.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2130127471e293d385179c1f3f9cd445070c0772be73cdafb7cec9a3684c0458", size = 1129402, upload-time = "2025-08-08T23:57:39.627Z" }, + { url = "https://files.pythonhosted.org/packages/34/9a/db7a86b829e05a01fd4daa492086f708e0a8b53952e1dbc9d380d2b03677/tiktoken-0.11.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21e43022bf2c33f733ea9b54f6a3f6b4354b909f5a73388fb1b9347ca54a069c", size = 1184046, upload-time = "2025-08-08T23:57:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/9d/bb/52edc8e078cf062ed749248f1454e9e5cfd09979baadb830b3940e522015/tiktoken-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:adb4e308eb64380dc70fa30493e21c93475eaa11669dea313b6bbf8210bfd013", size = 1244691, upload-time = "2025-08-08T23:57:42.251Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/884b6cd7ae2570ecdcaffa02b528522b18fef1cbbfdbcaa73799807d0d3b/tiktoken-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:ece6b76bfeeb61a125c44bbefdfccc279b5288e6007fbedc0d32bfec602df2f2", size = 884392, upload-time = "2025-08-08T23:57:43.628Z" }, + { url = "https://files.pythonhosted.org/packages/e7/9e/eceddeffc169fc75fe0fd4f38471309f11cb1906f9b8aa39be4f5817df65/tiktoken-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fd9e6b23e860973cf9526544e220b223c60badf5b62e80a33509d6d40e6c8f5d", size = 1055199, upload-time = "2025-08-08T23:57:45.076Z" }, + { url = "https://files.pythonhosted.org/packages/4f/cf/5f02bfefffdc6b54e5094d2897bc80efd43050e5b09b576fd85936ee54bf/tiktoken-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6a76d53cee2da71ee2731c9caa747398762bda19d7f92665e882fef229cb0b5b", size = 996655, upload-time = "2025-08-08T23:57:46.304Z" }, + { url = "https://files.pythonhosted.org/packages/65/8e/c769b45ef379bc360c9978c4f6914c79fd432400a6733a8afc7ed7b0726a/tiktoken-0.11.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ef72aab3ea240646e642413cb363b73869fed4e604dcfd69eec63dc54d603e8", size = 1128867, upload-time = "2025-08-08T23:57:47.438Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2d/4d77f6feb9292bfdd23d5813e442b3bba883f42d0ac78ef5fdc56873f756/tiktoken-0.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f929255c705efec7a28bf515e29dc74220b2f07544a8c81b8d69e8efc4578bd", size = 1183308, upload-time = "2025-08-08T23:57:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/7a/65/7ff0a65d3bb0fc5a1fb6cc71b03e0f6e71a68c5eea230d1ff1ba3fd6df49/tiktoken-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:61f1d15822e4404953d499fd1dcc62817a12ae9fb1e4898033ec8fe3915fdf8e", size = 1244301, upload-time = "2025-08-08T23:57:49.642Z" }, + { url = "https://files.pythonhosted.org/packages/f5/6e/5b71578799b72e5bdcef206a214c3ce860d999d579a3b56e74a6c8989ee2/tiktoken-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:45927a71ab6643dfd3ef57d515a5db3d199137adf551f66453be098502838b0f", size = 884282, upload-time = "2025-08-08T23:57:50.759Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cd/a9034bcee638716d9310443818d73c6387a6a96db93cbcb0819b77f5b206/tiktoken-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a5f3f25ffb152ee7fec78e90a5e5ea5b03b4ea240beed03305615847f7a6ace2", size = 1055339, upload-time = "2025-08-08T23:57:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/f1/91/9922b345f611b4e92581f234e64e9661e1c524875c8eadd513c4b2088472/tiktoken-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7dc6e9ad16a2a75b4c4be7208055a1f707c9510541d94d9cc31f7fbdc8db41d8", size = 997080, upload-time = "2025-08-08T23:57:53.442Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9d/49cd047c71336bc4b4af460ac213ec1c457da67712bde59b892e84f1859f/tiktoken-0.11.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a0517634d67a8a48fd4a4ad73930c3022629a85a217d256a6e9b8b47439d1e4", size = 1128501, upload-time = "2025-08-08T23:57:54.808Z" }, + { url = "https://files.pythonhosted.org/packages/52/d5/a0dcdb40dd2ea357e83cb36258967f0ae96f5dd40c722d6e382ceee6bba9/tiktoken-0.11.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fb4effe60574675118b73c6fbfd3b5868e5d7a1f570d6cc0d18724b09ecf318", size = 1182743, upload-time = "2025-08-08T23:57:56.307Z" }, + { url = "https://files.pythonhosted.org/packages/3b/17/a0fc51aefb66b7b5261ca1314afa83df0106b033f783f9a7bcbe8e741494/tiktoken-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94f984c9831fd32688aef4348803b0905d4ae9c432303087bae370dc1381a2b8", size = 1244057, upload-time = "2025-08-08T23:57:57.628Z" }, + { url = "https://files.pythonhosted.org/packages/50/79/bcf350609f3a10f09fe4fc207f132085e497fdd3612f3925ab24d86a0ca0/tiktoken-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:2177ffda31dec4023356a441793fed82f7af5291120751dee4d696414f54db0c", size = 883901, upload-time = "2025-08-08T23:57:59.359Z" }, ] [[package]] @@ -1822,7 +2051,7 @@ wheels = [ [[package]] name = "typer" -version = "0.15.1" +version = "0.16.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -1830,40 +2059,52 @@ dependencies = [ { name = "shellingham" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/dca7b219718afd37a0068f4f2530a727c2b74a8b6e8e0c0080a4c0de4fcd/typer-0.15.1.tar.gz", hash = "sha256:a0588c0a7fa68a1978a069818657778f86abe6ff5ea6abf472f940a08bfe4f0a", size = 99789, upload-time = "2024-12-04T17:44:58.956Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/78/d90f616bf5f88f8710ad067c1f8705bf7618059836ca084e5bb2a0855d75/typer-0.16.1.tar.gz", hash = "sha256:d358c65a464a7a90f338e3bb7ff0c74ac081449e53884b12ba658cbd72990614", size = 102836, upload-time = "2025-08-18T19:18:22.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/cc/0a838ba5ca64dc832aa43f727bd586309846b0ffb2ce52422543e6075e8a/typer-0.15.1-py3-none-any.whl", hash = "sha256:7994fb7b8155b64d3402518560648446072864beefd44aa2dc36972a5972e847", size = 44908, upload-time = "2024-12-04T17:44:57.291Z" }, + { url = "https://files.pythonhosted.org/packages/2d/76/06dbe78f39b2203d2a47d5facc5df5102d0561e2807396471b5f7c5a30a1/typer-0.16.1-py3-none-any.whl", hash = "sha256:90ee01cb02d9b8395ae21ee3368421faf21fa138cb2a541ed369c08cec5237c9", size = 46397, upload-time = "2025-08-18T19:18:21.663Z" }, ] [[package]] name = "typing-extensions" -version = "4.12.2" +version = "4.14.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321, upload-time = "2024-06-07T18:52:15.995Z" } +sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438, upload-time = "2024-06-07T18:52:13.582Z" }, + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, ] [[package]] name = "urllib3" -version = "2.3.0" +version = "2.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268, upload-time = "2024-12-22T07:47:30.032Z" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369, upload-time = "2024-12-22T07:47:28.074Z" }, + { 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 = "uvicorn" -version = "0.34.0" +version = "0.35.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568, upload-time = "2024-12-15T13:33:30.42Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315, upload-time = "2024-12-15T13:33:27.467Z" }, + { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" }, ] [package.optional-dependencies] @@ -1905,153 +2146,208 @@ wheels = [ [[package]] name = "watchfiles" -version = "1.0.4" +version = "1.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f5/26/c705fc77d0a9ecdb9b66f1e2976d95b81df3cae518967431e7dbf9b5e219/watchfiles-1.0.4.tar.gz", hash = "sha256:6ba473efd11062d73e4f00c2b730255f9c1bdd73cd5f9fe5b5da8dbd4a717205", size = 94625, upload-time = "2025-01-10T13:05:56.196Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/bb/8461adc4b1fed009546fb797fc0d5698dcfe5e289cb37e1b8f16a93cdc30/watchfiles-1.0.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:2a9f93f8439639dc244c4d2902abe35b0279102bca7bbcf119af964f51d53c19", size = 394869, upload-time = "2025-01-10T13:03:37.906Z" }, - { url = "https://files.pythonhosted.org/packages/55/88/9ebf36b3547176d1709c320de78c1fa3263a46be31b5b1267571d9102686/watchfiles-1.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9eea33ad8c418847dd296e61eb683cae1c63329b6d854aefcd412e12d94ee235", size = 384905, upload-time = "2025-01-10T13:03:39.562Z" }, - { url = "https://files.pythonhosted.org/packages/03/8a/04335ce23ef78d8c69f0913e8b20cf7d9233e3986543aeef95ef2d6e43d2/watchfiles-1.0.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31f1a379c9dcbb3f09cf6be1b7e83b67c0e9faabed0471556d9438a4a4e14202", size = 449944, upload-time = "2025-01-10T13:03:42.483Z" }, - { url = "https://files.pythonhosted.org/packages/17/4e/c8d5dcd14fe637f4633616dabea8a4af0a10142dccf3b43e0f081ba81ab4/watchfiles-1.0.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ab594e75644421ae0a2484554832ca5895f8cab5ab62de30a1a57db460ce06c6", size = 456020, upload-time = "2025-01-10T13:03:45.449Z" }, - { url = "https://files.pythonhosted.org/packages/5e/74/3e91e09e1861dd7fbb1190ce7bd786700dc0fbc2ccd33bb9fff5de039229/watchfiles-1.0.4-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fc2eb5d14a8e0d5df7b36288979176fbb39672d45184fc4b1c004d7c3ce29317", size = 482983, upload-time = "2025-01-10T13:03:47.082Z" }, - { url = "https://files.pythonhosted.org/packages/a1/3d/e64de2d1ce4eb6a574fd78ce3a28c279da263be9ef3cfcab6f708df192f2/watchfiles-1.0.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f68d8e9d5a321163ddacebe97091000955a1b74cd43724e346056030b0bacee", size = 520320, upload-time = "2025-01-10T13:03:48.976Z" }, - { url = "https://files.pythonhosted.org/packages/2c/bd/52235f7063b57240c66a991696ed27e2a18bd6fcec8a1ea5a040b70d0611/watchfiles-1.0.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f9ce064e81fe79faa925ff03b9f4c1a98b0bbb4a1b8c1b015afa93030cb21a49", size = 500988, upload-time = "2025-01-10T13:03:50.543Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b0/ff04194141a5fe650c150400dd9e42667916bc0f52426e2e174d779b8a74/watchfiles-1.0.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b77d5622ac5cc91d21ae9c2b284b5d5c51085a0bdb7b518dba263d0af006132c", size = 452573, upload-time = "2025-01-10T13:03:53.918Z" }, - { url = "https://files.pythonhosted.org/packages/3d/9d/966164332c5a178444ae6d165082d4f351bd56afd9c3ec828eecbf190e6a/watchfiles-1.0.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1941b4e39de9b38b868a69b911df5e89dc43767feeda667b40ae032522b9b5f1", size = 615114, upload-time = "2025-01-10T13:03:56.881Z" }, - { url = "https://files.pythonhosted.org/packages/94/df/f569ae4c1877f96ad4086c153a8eee5a19a3b519487bf5c9454a3438c341/watchfiles-1.0.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4f8c4998506241dedf59613082d1c18b836e26ef2a4caecad0ec41e2a15e4226", size = 613076, upload-time = "2025-01-10T13:04:00.751Z" }, - { url = "https://files.pythonhosted.org/packages/15/ae/8ce5f29e65d5fa5790e3c80c289819c55e12be2e1b9f5b6a0e55e169b97d/watchfiles-1.0.4-cp311-cp311-win32.whl", hash = "sha256:4ebbeca9360c830766b9f0df3640b791be569d988f4be6c06d6fae41f187f105", size = 271013, upload-time = "2025-01-10T13:04:08.455Z" }, - { url = "https://files.pythonhosted.org/packages/a4/c6/79dc4a7c598a978e5fafa135090aaf7bbb03b8dec7bada437dfbe578e7ed/watchfiles-1.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:05d341c71f3d7098920f8551d4df47f7b57ac5b8dad56558064c3431bdfc0b74", size = 284229, upload-time = "2025-01-10T13:04:11.283Z" }, - { url = "https://files.pythonhosted.org/packages/37/3d/928633723211753f3500bfb138434f080363b87a1b08ca188b1ce54d1e05/watchfiles-1.0.4-cp311-cp311-win_arm64.whl", hash = "sha256:32b026a6ab64245b584acf4931fe21842374da82372d5c039cba6bf99ef722f3", size = 276824, upload-time = "2025-01-10T13:04:14.202Z" }, - { url = "https://files.pythonhosted.org/packages/5b/1a/8f4d9a1461709756ace48c98f07772bc6d4519b1e48b5fa24a4061216256/watchfiles-1.0.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:229e6ec880eca20e0ba2f7e2249c85bae1999d330161f45c78d160832e026ee2", size = 391345, upload-time = "2025-01-10T13:04:17.001Z" }, - { url = "https://files.pythonhosted.org/packages/bc/d2/6750b7b3527b1cdaa33731438432e7238a6c6c40a9924049e4cebfa40805/watchfiles-1.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5717021b199e8353782dce03bd8a8f64438832b84e2885c4a645f9723bf656d9", size = 381515, upload-time = "2025-01-10T13:04:21.27Z" }, - { url = "https://files.pythonhosted.org/packages/4e/17/80500e42363deef1e4b4818729ed939aaddc56f82f4e72b2508729dd3c6b/watchfiles-1.0.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0799ae68dfa95136dde7c472525700bd48777875a4abb2ee454e3ab18e9fc712", size = 449767, upload-time = "2025-01-10T13:04:23.745Z" }, - { url = "https://files.pythonhosted.org/packages/10/37/1427fa4cfa09adbe04b1e97bced19a29a3462cc64c78630787b613a23f18/watchfiles-1.0.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43b168bba889886b62edb0397cab5b6490ffb656ee2fcb22dec8bfeb371a9e12", size = 455677, upload-time = "2025-01-10T13:04:27.618Z" }, - { url = "https://files.pythonhosted.org/packages/c5/7a/39e9397f3a19cb549a7d380412fd9e507d4854eddc0700bfad10ef6d4dba/watchfiles-1.0.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb2c46e275fbb9f0c92e7654b231543c7bbfa1df07cdc4b99fa73bedfde5c844", size = 482219, upload-time = "2025-01-10T13:04:29.265Z" }, - { url = "https://files.pythonhosted.org/packages/45/2d/7113931a77e2ea4436cad0c1690c09a40a7f31d366f79c6f0a5bc7a4f6d5/watchfiles-1.0.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:857f5fc3aa027ff5e57047da93f96e908a35fe602d24f5e5d8ce64bf1f2fc733", size = 518830, upload-time = "2025-01-10T13:04:31.957Z" }, - { url = "https://files.pythonhosted.org/packages/f9/1b/50733b1980fa81ef3c70388a546481ae5fa4c2080040100cd7bf3bf7b321/watchfiles-1.0.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55ccfd27c497b228581e2838d4386301227fc0cb47f5a12923ec2fe4f97b95af", size = 497997, upload-time = "2025-01-10T13:04:33.938Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b4/9396cc61b948ef18943e7c85ecfa64cf940c88977d882da57147f62b34b1/watchfiles-1.0.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c11ea22304d17d4385067588123658e9f23159225a27b983f343fcffc3e796a", size = 452249, upload-time = "2025-01-10T13:04:35.559Z" }, - { url = "https://files.pythonhosted.org/packages/fb/69/0c65a5a29e057ad0dc691c2fa6c23b2983c7dabaa190ba553b29ac84c3cc/watchfiles-1.0.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:74cb3ca19a740be4caa18f238298b9d472c850f7b2ed89f396c00a4c97e2d9ff", size = 614412, upload-time = "2025-01-10T13:04:37.061Z" }, - { url = "https://files.pythonhosted.org/packages/7f/b9/319fcba6eba5fad34327d7ce16a6b163b39741016b1996f4a3c96b8dd0e1/watchfiles-1.0.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c7cce76c138a91e720d1df54014a047e680b652336e1b73b8e3ff3158e05061e", size = 611982, upload-time = "2025-01-10T13:04:38.995Z" }, - { url = "https://files.pythonhosted.org/packages/f1/47/143c92418e30cb9348a4387bfa149c8e0e404a7c5b0585d46d2f7031b4b9/watchfiles-1.0.4-cp312-cp312-win32.whl", hash = "sha256:b045c800d55bc7e2cadd47f45a97c7b29f70f08a7c2fa13241905010a5493f94", size = 271822, upload-time = "2025-01-10T13:04:40.516Z" }, - { url = "https://files.pythonhosted.org/packages/ea/94/b0165481bff99a64b29e46e07ac2e0df9f7a957ef13bec4ceab8515f44e3/watchfiles-1.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:c2acfa49dd0ad0bf2a9c0bb9a985af02e89345a7189be1efc6baa085e0f72d7c", size = 285441, upload-time = "2025-01-10T13:04:42.853Z" }, - { url = "https://files.pythonhosted.org/packages/11/de/09fe56317d582742d7ca8c2ca7b52a85927ebb50678d9b0fa8194658f536/watchfiles-1.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:22bb55a7c9e564e763ea06c7acea24fc5d2ee5dfc5dafc5cfbedfe58505e9f90", size = 277141, upload-time = "2025-01-10T13:04:45.914Z" }, - { url = "https://files.pythonhosted.org/packages/08/98/f03efabec64b5b1fa58c0daab25c68ef815b0f320e54adcacd0d6847c339/watchfiles-1.0.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:8012bd820c380c3d3db8435e8cf7592260257b378b649154a7948a663b5f84e9", size = 390954, upload-time = "2025-01-10T13:04:47.458Z" }, - { url = "https://files.pythonhosted.org/packages/16/09/4dd49ba0a32a45813debe5fb3897955541351ee8142f586303b271a02b40/watchfiles-1.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa216f87594f951c17511efe5912808dfcc4befa464ab17c98d387830ce07b60", size = 381133, upload-time = "2025-01-10T13:04:48.977Z" }, - { url = "https://files.pythonhosted.org/packages/76/59/5aa6fc93553cd8d8ee75c6247763d77c02631aed21551a97d94998bf1dae/watchfiles-1.0.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62c9953cf85529c05b24705639ffa390f78c26449e15ec34d5339e8108c7c407", size = 449516, upload-time = "2025-01-10T13:04:50.653Z" }, - { url = "https://files.pythonhosted.org/packages/4c/aa/df4b6fe14b6317290b91335b23c96b488d365d65549587434817e06895ea/watchfiles-1.0.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7cf684aa9bba4cd95ecb62c822a56de54e3ae0598c1a7f2065d51e24637a3c5d", size = 454820, upload-time = "2025-01-10T13:04:52.312Z" }, - { url = "https://files.pythonhosted.org/packages/5e/71/185f8672f1094ce48af33252c73e39b48be93b761273872d9312087245f6/watchfiles-1.0.4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f44a39aee3cbb9b825285ff979ab887a25c5d336e5ec3574f1506a4671556a8d", size = 481550, upload-time = "2025-01-10T13:04:54.007Z" }, - { url = "https://files.pythonhosted.org/packages/85/d7/50ebba2c426ef1a5cb17f02158222911a2e005d401caf5d911bfca58f4c4/watchfiles-1.0.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a38320582736922be8c865d46520c043bff350956dfc9fbaee3b2df4e1740a4b", size = 518647, upload-time = "2025-01-10T13:04:56.008Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/4c009342e393c545d68987e8010b937f72f47937731225b2b29b7231428f/watchfiles-1.0.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39f4914548b818540ef21fd22447a63e7be6e24b43a70f7642d21f1e73371590", size = 497547, upload-time = "2025-01-10T13:04:58.087Z" }, - { url = "https://files.pythonhosted.org/packages/0f/7c/1cf50b35412d5c72d63b2bf9a4fffee2e1549a245924960dd087eb6a6de4/watchfiles-1.0.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f12969a3765909cf5dc1e50b2436eb2c0e676a3c75773ab8cc3aa6175c16e902", size = 452179, upload-time = "2025-01-10T13:05:01.175Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a9/3db1410e1c1413735a9a472380e4f431ad9a9e81711cda2aaf02b7f62693/watchfiles-1.0.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:0986902677a1a5e6212d0c49b319aad9cc48da4bd967f86a11bde96ad9676ca1", size = 614125, upload-time = "2025-01-10T13:05:03.086Z" }, - { url = "https://files.pythonhosted.org/packages/f2/e1/0025d365cf6248c4d1ee4c3d2e3d373bdd3f6aff78ba4298f97b4fad2740/watchfiles-1.0.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:308ac265c56f936636e3b0e3f59e059a40003c655228c131e1ad439957592303", size = 611911, upload-time = "2025-01-10T13:05:04.947Z" }, - { url = "https://files.pythonhosted.org/packages/55/55/035838277d8c98fc8c917ac9beeb0cd6c59d675dc2421df5f9fcf44a0070/watchfiles-1.0.4-cp313-cp313-win32.whl", hash = "sha256:aee397456a29b492c20fda2d8961e1ffb266223625346ace14e4b6d861ba9c80", size = 271152, upload-time = "2025-01-10T13:05:09.507Z" }, - { url = "https://files.pythonhosted.org/packages/f0/e5/96b8e55271685ddbadc50ce8bc53aa2dff278fb7ac4c2e473df890def2dc/watchfiles-1.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:d6097538b0ae5c1b88c3b55afa245a66793a8fec7ada6755322e465fb1a0e8cc", size = 285216, upload-time = "2025-01-10T13:05:11.107Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/2a/9a/d451fcc97d029f5812e898fd30a53fd8c15c7bbd058fd75cfc6beb9bd761/watchfiles-1.1.0.tar.gz", hash = "sha256:693ed7ec72cbfcee399e92c895362b6e66d63dac6b91e2c11ae03d10d503e575", size = 94406, upload-time = "2025-06-15T19:06:59.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/78/7401154b78ab484ccaaeef970dc2af0cb88b5ba8a1b415383da444cdd8d3/watchfiles-1.1.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c9649dfc57cc1f9835551deb17689e8d44666315f2e82d337b9f07bd76ae3aa2", size = 405751, upload-time = "2025-06-15T19:05:07.679Z" }, + { url = "https://files.pythonhosted.org/packages/76/63/e6c3dbc1f78d001589b75e56a288c47723de28c580ad715eb116639152b5/watchfiles-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:406520216186b99374cdb58bc48e34bb74535adec160c8459894884c983a149c", size = 397313, upload-time = "2025-06-15T19:05:08.764Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a2/8afa359ff52e99af1632f90cbf359da46184207e893a5f179301b0c8d6df/watchfiles-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb45350fd1dc75cd68d3d72c47f5b513cb0578da716df5fba02fff31c69d5f2d", size = 450792, upload-time = "2025-06-15T19:05:09.869Z" }, + { url = "https://files.pythonhosted.org/packages/1d/bf/7446b401667f5c64972a57a0233be1104157fc3abf72c4ef2666c1bd09b2/watchfiles-1.1.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:11ee4444250fcbeb47459a877e5e80ed994ce8e8d20283857fc128be1715dac7", size = 458196, upload-time = "2025-06-15T19:05:11.91Z" }, + { url = "https://files.pythonhosted.org/packages/58/2f/501ddbdfa3fa874ea5597c77eeea3d413579c29af26c1091b08d0c792280/watchfiles-1.1.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bda8136e6a80bdea23e5e74e09df0362744d24ffb8cd59c4a95a6ce3d142f79c", size = 484788, upload-time = "2025-06-15T19:05:13.373Z" }, + { url = "https://files.pythonhosted.org/packages/61/1e/9c18eb2eb5c953c96bc0e5f626f0e53cfef4bd19bd50d71d1a049c63a575/watchfiles-1.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b915daeb2d8c1f5cee4b970f2e2c988ce6514aace3c9296e58dd64dc9aa5d575", size = 597879, upload-time = "2025-06-15T19:05:14.725Z" }, + { url = "https://files.pythonhosted.org/packages/8b/6c/1467402e5185d89388b4486745af1e0325007af0017c3384cc786fff0542/watchfiles-1.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed8fc66786de8d0376f9f913c09e963c66e90ced9aa11997f93bdb30f7c872a8", size = 477447, upload-time = "2025-06-15T19:05:15.775Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a1/ec0a606bde4853d6c4a578f9391eeb3684a9aea736a8eb217e3e00aa89a1/watchfiles-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe4371595edf78c41ef8ac8df20df3943e13defd0efcb732b2e393b5a8a7a71f", size = 453145, upload-time = "2025-06-15T19:05:17.17Z" }, + { url = "https://files.pythonhosted.org/packages/90/b9/ef6f0c247a6a35d689fc970dc7f6734f9257451aefb30def5d100d6246a5/watchfiles-1.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b7c5f6fe273291f4d414d55b2c80d33c457b8a42677ad14b4b47ff025d0893e4", size = 626539, upload-time = "2025-06-15T19:05:18.557Z" }, + { url = "https://files.pythonhosted.org/packages/34/44/6ffda5537085106ff5aaa762b0d130ac6c75a08015dd1621376f708c94de/watchfiles-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7738027989881e70e3723c75921f1efa45225084228788fc59ea8c6d732eb30d", size = 624472, upload-time = "2025-06-15T19:05:19.588Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e3/71170985c48028fa3f0a50946916a14055e741db11c2e7bc2f3b61f4d0e3/watchfiles-1.1.0-cp311-cp311-win32.whl", hash = "sha256:622d6b2c06be19f6e89b1d951485a232e3b59618def88dbeda575ed8f0d8dbf2", size = 279348, upload-time = "2025-06-15T19:05:20.856Z" }, + { url = "https://files.pythonhosted.org/packages/89/1b/3e39c68b68a7a171070f81fc2561d23ce8d6859659406842a0e4bebf3bba/watchfiles-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:48aa25e5992b61debc908a61ab4d3f216b64f44fdaa71eb082d8b2de846b7d12", size = 292607, upload-time = "2025-06-15T19:05:21.937Z" }, + { url = "https://files.pythonhosted.org/packages/61/9f/2973b7539f2bdb6ea86d2c87f70f615a71a1fc2dba2911795cea25968aea/watchfiles-1.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:00645eb79a3faa70d9cb15c8d4187bb72970b2470e938670240c7998dad9f13a", size = 285056, upload-time = "2025-06-15T19:05:23.12Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b8/858957045a38a4079203a33aaa7d23ea9269ca7761c8a074af3524fbb240/watchfiles-1.1.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9dc001c3e10de4725c749d4c2f2bdc6ae24de5a88a339c4bce32300a31ede179", size = 402339, upload-time = "2025-06-15T19:05:24.516Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/98b222cca751ba68e88521fabd79a4fab64005fc5976ea49b53fa205d1fa/watchfiles-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9ba68ec283153dead62cbe81872d28e053745f12335d037de9cbd14bd1877f5", size = 394409, upload-time = "2025-06-15T19:05:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/50/dee79968566c03190677c26f7f47960aff738d32087087bdf63a5473e7df/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:130fc497b8ee68dce163e4254d9b0356411d1490e868bd8790028bc46c5cc297", size = 450939, upload-time = "2025-06-15T19:05:26.494Z" }, + { url = "https://files.pythonhosted.org/packages/40/45/a7b56fb129700f3cfe2594a01aa38d033b92a33dddce86c8dfdfc1247b72/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:50a51a90610d0845a5931a780d8e51d7bd7f309ebc25132ba975aca016b576a0", size = 457270, upload-time = "2025-06-15T19:05:27.466Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c8/fa5ef9476b1d02dc6b5e258f515fcaaecf559037edf8b6feffcbc097c4b8/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc44678a72ac0910bac46fa6a0de6af9ba1355669b3dfaf1ce5f05ca7a74364e", size = 483370, upload-time = "2025-06-15T19:05:28.548Z" }, + { url = "https://files.pythonhosted.org/packages/98/68/42cfcdd6533ec94f0a7aab83f759ec11280f70b11bfba0b0f885e298f9bd/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a543492513a93b001975ae283a51f4b67973662a375a403ae82f420d2c7205ee", size = 598654, upload-time = "2025-06-15T19:05:29.997Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/b2a1544224118cc28df7e59008a929e711f9c68ce7d554e171b2dc531352/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ac164e20d17cc285f2b94dc31c384bc3aa3dd5e7490473b3db043dd70fbccfd", size = 478667, upload-time = "2025-06-15T19:05:31.172Z" }, + { url = "https://files.pythonhosted.org/packages/8c/77/e3362fe308358dc9f8588102481e599c83e1b91c2ae843780a7ded939a35/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7590d5a455321e53857892ab8879dce62d1f4b04748769f5adf2e707afb9d4f", size = 452213, upload-time = "2025-06-15T19:05:32.299Z" }, + { url = "https://files.pythonhosted.org/packages/6e/17/c8f1a36540c9a1558d4faf08e909399e8133599fa359bf52ec8fcee5be6f/watchfiles-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:37d3d3f7defb13f62ece99e9be912afe9dd8a0077b7c45ee5a57c74811d581a4", size = 626718, upload-time = "2025-06-15T19:05:33.415Z" }, + { url = "https://files.pythonhosted.org/packages/26/45/fb599be38b4bd38032643783d7496a26a6f9ae05dea1a42e58229a20ac13/watchfiles-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7080c4bb3efd70a07b1cc2df99a7aa51d98685be56be6038c3169199d0a1c69f", size = 623098, upload-time = "2025-06-15T19:05:34.534Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/fdf40e038475498e160cd167333c946e45d8563ae4dd65caf757e9ffe6b4/watchfiles-1.1.0-cp312-cp312-win32.whl", hash = "sha256:cbcf8630ef4afb05dc30107bfa17f16c0896bb30ee48fc24bf64c1f970f3b1fd", size = 279209, upload-time = "2025-06-15T19:05:35.577Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d3/3ae9d5124ec75143bdf088d436cba39812122edc47709cd2caafeac3266f/watchfiles-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:cbd949bdd87567b0ad183d7676feb98136cde5bb9025403794a4c0db28ed3a47", size = 292786, upload-time = "2025-06-15T19:05:36.559Z" }, + { url = "https://files.pythonhosted.org/packages/26/2f/7dd4fc8b5f2b34b545e19629b4a018bfb1de23b3a496766a2c1165ca890d/watchfiles-1.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:0a7d40b77f07be87c6faa93d0951a0fcd8cbca1ddff60a1b65d741bac6f3a9f6", size = 284343, upload-time = "2025-06-15T19:05:37.5Z" }, + { url = "https://files.pythonhosted.org/packages/d3/42/fae874df96595556a9089ade83be34a2e04f0f11eb53a8dbf8a8a5e562b4/watchfiles-1.1.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5007f860c7f1f8df471e4e04aaa8c43673429047d63205d1630880f7637bca30", size = 402004, upload-time = "2025-06-15T19:05:38.499Z" }, + { url = "https://files.pythonhosted.org/packages/fa/55/a77e533e59c3003d9803c09c44c3651224067cbe7fb5d574ddbaa31e11ca/watchfiles-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:20ecc8abbd957046f1fe9562757903f5eaf57c3bce70929fda6c7711bb58074a", size = 393671, upload-time = "2025-06-15T19:05:39.52Z" }, + { url = "https://files.pythonhosted.org/packages/05/68/b0afb3f79c8e832e6571022611adbdc36e35a44e14f129ba09709aa4bb7a/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2f0498b7d2a3c072766dba3274fe22a183dbea1f99d188f1c6c72209a1063dc", size = 449772, upload-time = "2025-06-15T19:05:40.897Z" }, + { url = "https://files.pythonhosted.org/packages/ff/05/46dd1f6879bc40e1e74c6c39a1b9ab9e790bf1f5a2fe6c08b463d9a807f4/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:239736577e848678e13b201bba14e89718f5c2133dfd6b1f7846fa1b58a8532b", size = 456789, upload-time = "2025-06-15T19:05:42.045Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ca/0eeb2c06227ca7f12e50a47a3679df0cd1ba487ea19cf844a905920f8e95/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eff4b8d89f444f7e49136dc695599a591ff769300734446c0a86cba2eb2f9895", size = 482551, upload-time = "2025-06-15T19:05:43.781Z" }, + { url = "https://files.pythonhosted.org/packages/31/47/2cecbd8694095647406645f822781008cc524320466ea393f55fe70eed3b/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12b0a02a91762c08f7264e2e79542f76870c3040bbc847fb67410ab81474932a", size = 597420, upload-time = "2025-06-15T19:05:45.244Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7e/82abc4240e0806846548559d70f0b1a6dfdca75c1b4f9fa62b504ae9b083/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29e7bc2eee15cbb339c68445959108803dc14ee0c7b4eea556400131a8de462b", size = 477950, upload-time = "2025-06-15T19:05:46.332Z" }, + { url = "https://files.pythonhosted.org/packages/25/0d/4d564798a49bf5482a4fa9416dea6b6c0733a3b5700cb8a5a503c4b15853/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9481174d3ed982e269c090f780122fb59cee6c3796f74efe74e70f7780ed94c", size = 451706, upload-time = "2025-06-15T19:05:47.459Z" }, + { url = "https://files.pythonhosted.org/packages/81/b5/5516cf46b033192d544102ea07c65b6f770f10ed1d0a6d388f5d3874f6e4/watchfiles-1.1.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:80f811146831c8c86ab17b640801c25dc0a88c630e855e2bef3568f30434d52b", size = 625814, upload-time = "2025-06-15T19:05:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/0c/dd/7c1331f902f30669ac3e754680b6edb9a0dd06dea5438e61128111fadd2c/watchfiles-1.1.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:60022527e71d1d1fda67a33150ee42869042bce3d0fcc9cc49be009a9cded3fb", size = 622820, upload-time = "2025-06-15T19:05:50.088Z" }, + { url = "https://files.pythonhosted.org/packages/1b/14/36d7a8e27cd128d7b1009e7715a7c02f6c131be9d4ce1e5c3b73d0e342d8/watchfiles-1.1.0-cp313-cp313-win32.whl", hash = "sha256:32d6d4e583593cb8576e129879ea0991660b935177c0f93c6681359b3654bfa9", size = 279194, upload-time = "2025-06-15T19:05:51.186Z" }, + { url = "https://files.pythonhosted.org/packages/25/41/2dd88054b849aa546dbeef5696019c58f8e0774f4d1c42123273304cdb2e/watchfiles-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:f21af781a4a6fbad54f03c598ab620e3a77032c5878f3d780448421a6e1818c7", size = 292349, upload-time = "2025-06-15T19:05:52.201Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cf/421d659de88285eb13941cf11a81f875c176f76a6d99342599be88e08d03/watchfiles-1.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:5366164391873ed76bfdf618818c82084c9db7fac82b64a20c44d335eec9ced5", size = 283836, upload-time = "2025-06-15T19:05:53.265Z" }, + { url = "https://files.pythonhosted.org/packages/45/10/6faf6858d527e3599cc50ec9fcae73590fbddc1420bd4fdccfebffeedbc6/watchfiles-1.1.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:17ab167cca6339c2b830b744eaf10803d2a5b6683be4d79d8475d88b4a8a4be1", size = 400343, upload-time = "2025-06-15T19:05:54.252Z" }, + { url = "https://files.pythonhosted.org/packages/03/20/5cb7d3966f5e8c718006d0e97dfe379a82f16fecd3caa7810f634412047a/watchfiles-1.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:328dbc9bff7205c215a7807da7c18dce37da7da718e798356212d22696404339", size = 392916, upload-time = "2025-06-15T19:05:55.264Z" }, + { url = "https://files.pythonhosted.org/packages/8c/07/d8f1176328fa9e9581b6f120b017e286d2a2d22ae3f554efd9515c8e1b49/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7208ab6e009c627b7557ce55c465c98967e8caa8b11833531fdf95799372633", size = 449582, upload-time = "2025-06-15T19:05:56.317Z" }, + { url = "https://files.pythonhosted.org/packages/66/e8/80a14a453cf6038e81d072a86c05276692a1826471fef91df7537dba8b46/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8f6f72974a19efead54195bc9bed4d850fc047bb7aa971268fd9a8387c89011", size = 456752, upload-time = "2025-06-15T19:05:57.359Z" }, + { url = "https://files.pythonhosted.org/packages/5a/25/0853b3fe0e3c2f5af9ea60eb2e781eade939760239a72c2d38fc4cc335f6/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d181ef50923c29cf0450c3cd47e2f0557b62218c50b2ab8ce2ecaa02bd97e670", size = 481436, upload-time = "2025-06-15T19:05:58.447Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9e/4af0056c258b861fbb29dcb36258de1e2b857be4a9509e6298abcf31e5c9/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adb4167043d3a78280d5d05ce0ba22055c266cf8655ce942f2fb881262ff3cdf", size = 596016, upload-time = "2025-06-15T19:05:59.59Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fa/95d604b58aa375e781daf350897aaaa089cff59d84147e9ccff2447c8294/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5701dc474b041e2934a26d31d39f90fac8a3dee2322b39f7729867f932b1d4", size = 476727, upload-time = "2025-06-15T19:06:01.086Z" }, + { url = "https://files.pythonhosted.org/packages/65/95/fe479b2664f19be4cf5ceeb21be05afd491d95f142e72d26a42f41b7c4f8/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b067915e3c3936966a8607f6fe5487df0c9c4afb85226613b520890049deea20", size = 451864, upload-time = "2025-06-15T19:06:02.144Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8a/3c4af14b93a15ce55901cd7a92e1a4701910f1768c78fb30f61d2b79785b/watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:9c733cda03b6d636b4219625a4acb5c6ffb10803338e437fb614fef9516825ef", size = 625626, upload-time = "2025-06-15T19:06:03.578Z" }, + { url = "https://files.pythonhosted.org/packages/da/f5/cf6aa047d4d9e128f4b7cde615236a915673775ef171ff85971d698f3c2c/watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:cc08ef8b90d78bfac66f0def80240b0197008e4852c9f285907377b2947ffdcb", size = 622744, upload-time = "2025-06-15T19:06:05.066Z" }, + { url = "https://files.pythonhosted.org/packages/2c/00/70f75c47f05dea6fd30df90f047765f6fc2d6eb8b5a3921379b0b04defa2/watchfiles-1.1.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9974d2f7dc561cce3bb88dfa8eb309dab64c729de85fba32e98d75cf24b66297", size = 402114, upload-time = "2025-06-15T19:06:06.186Z" }, + { url = "https://files.pythonhosted.org/packages/53/03/acd69c48db4a1ed1de26b349d94077cca2238ff98fd64393f3e97484cae6/watchfiles-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c68e9f1fcb4d43798ad8814c4c1b61547b014b667216cb754e606bfade587018", size = 393879, upload-time = "2025-06-15T19:06:07.369Z" }, + { url = "https://files.pythonhosted.org/packages/2f/c8/a9a2a6f9c8baa4eceae5887fecd421e1b7ce86802bcfc8b6a942e2add834/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95ab1594377effac17110e1352989bdd7bdfca9ff0e5eeccd8c69c5389b826d0", size = 450026, upload-time = "2025-06-15T19:06:08.476Z" }, + { url = "https://files.pythonhosted.org/packages/fe/51/d572260d98388e6e2b967425c985e07d47ee6f62e6455cefb46a6e06eda5/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fba9b62da882c1be1280a7584ec4515d0a6006a94d6e5819730ec2eab60ffe12", size = 457917, upload-time = "2025-06-15T19:06:09.988Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2d/4258e52917bf9f12909b6ec314ff9636276f3542f9d3807d143f27309104/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3434e401f3ce0ed6b42569128b3d1e3af773d7ec18751b918b89cd49c14eaafb", size = 483602, upload-time = "2025-06-15T19:06:11.088Z" }, + { url = "https://files.pythonhosted.org/packages/84/99/bee17a5f341a4345fe7b7972a475809af9e528deba056f8963d61ea49f75/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa257a4d0d21fcbca5b5fcba9dca5a78011cb93c0323fb8855c6d2dfbc76eb77", size = 596758, upload-time = "2025-06-15T19:06:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/e4bec1d59b25b89d2b0716b41b461ed655a9a53c60dc78ad5771fda5b3e6/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fd1b3879a578a8ec2076c7961076df540b9af317123f84569f5a9ddee64ce92", size = 477601, upload-time = "2025-06-15T19:06:13.391Z" }, + { url = "https://files.pythonhosted.org/packages/1f/fa/a514292956f4a9ce3c567ec0c13cce427c158e9f272062685a8a727d08fc/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62cc7a30eeb0e20ecc5f4bd113cd69dcdb745a07c68c0370cea919f373f65d9e", size = 451936, upload-time = "2025-06-15T19:06:14.656Z" }, + { url = "https://files.pythonhosted.org/packages/32/5d/c3bf927ec3bbeb4566984eba8dd7a8eb69569400f5509904545576741f88/watchfiles-1.1.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:891c69e027748b4a73847335d208e374ce54ca3c335907d381fde4e41661b13b", size = 626243, upload-time = "2025-06-15T19:06:16.232Z" }, + { url = "https://files.pythonhosted.org/packages/e6/65/6e12c042f1a68c556802a84d54bb06d35577c81e29fba14019562479159c/watchfiles-1.1.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:12fe8eaffaf0faa7906895b4f8bb88264035b3f0243275e0bf24af0436b27259", size = 623073, upload-time = "2025-06-15T19:06:17.457Z" }, + { url = "https://files.pythonhosted.org/packages/89/ab/7f79d9bf57329e7cbb0a6fd4c7bd7d0cee1e4a8ef0041459f5409da3506c/watchfiles-1.1.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bfe3c517c283e484843cb2e357dd57ba009cff351edf45fb455b5fbd1f45b15f", size = 400872, upload-time = "2025-06-15T19:06:18.57Z" }, + { url = "https://files.pythonhosted.org/packages/df/d5/3f7bf9912798e9e6c516094db6b8932df53b223660c781ee37607030b6d3/watchfiles-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9ccbf1f129480ed3044f540c0fdbc4ee556f7175e5ab40fe077ff6baf286d4e", size = 392877, upload-time = "2025-06-15T19:06:19.55Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c5/54ec7601a2798604e01c75294770dbee8150e81c6e471445d7601610b495/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba0e3255b0396cac3cc7bbace76404dd72b5438bf0d8e7cefa2f79a7f3649caa", size = 449645, upload-time = "2025-06-15T19:06:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/0a/04/c2f44afc3b2fce21ca0b7802cbd37ed90a29874f96069ed30a36dfe57c2b/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4281cd9fce9fc0a9dbf0fc1217f39bf9cf2b4d315d9626ef1d4e87b84699e7e8", size = 457424, upload-time = "2025-06-15T19:06:21.712Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b0/eec32cb6c14d248095261a04f290636da3df3119d4040ef91a4a50b29fa5/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d2404af8db1329f9a3c9b79ff63e0ae7131986446901582067d9304ae8aaf7f", size = 481584, upload-time = "2025-06-15T19:06:22.777Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/ca4bb71c68a937d7145aa25709e4f5d68eb7698a25ce266e84b55d591bbd/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e78b6ed8165996013165eeabd875c5dfc19d41b54f94b40e9fff0eb3193e5e8e", size = 596675, upload-time = "2025-06-15T19:06:24.226Z" }, + { url = "https://files.pythonhosted.org/packages/a1/dd/b0e4b7fb5acf783816bc950180a6cd7c6c1d2cf7e9372c0ea634e722712b/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:249590eb75ccc117f488e2fabd1bfa33c580e24b96f00658ad88e38844a040bb", size = 477363, upload-time = "2025-06-15T19:06:25.42Z" }, + { url = "https://files.pythonhosted.org/packages/69/c4/088825b75489cb5b6a761a4542645718893d395d8c530b38734f19da44d2/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d05686b5487cfa2e2c28ff1aa370ea3e6c5accfe6435944ddea1e10d93872147", size = 452240, upload-time = "2025-06-15T19:06:26.552Z" }, + { url = "https://files.pythonhosted.org/packages/10/8c/22b074814970eeef43b7c44df98c3e9667c1f7bf5b83e0ff0201b0bd43f9/watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d0e10e6f8f6dc5762adee7dece33b722282e1f59aa6a55da5d493a97282fedd8", size = 625607, upload-time = "2025-06-15T19:06:27.606Z" }, + { url = "https://files.pythonhosted.org/packages/32/fa/a4f5c2046385492b2273213ef815bf71a0d4c1943b784fb904e184e30201/watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:af06c863f152005c7592df1d6a7009c836a247c9d8adb78fef8575a5a98699db", size = 623315, upload-time = "2025-06-15T19:06:29.076Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6b/686dcf5d3525ad17b384fd94708e95193529b460a1b7bf40851f1328ec6e/watchfiles-1.1.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0ece16b563b17ab26eaa2d52230c9a7ae46cf01759621f4fbbca280e438267b3", size = 406910, upload-time = "2025-06-15T19:06:49.335Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d3/71c2dcf81dc1edcf8af9f4d8d63b1316fb0a2dd90cbfd427e8d9dd584a90/watchfiles-1.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:51b81e55d40c4b4aa8658427a3ee7ea847c591ae9e8b81ef94a90b668999353c", size = 398816, upload-time = "2025-06-15T19:06:50.433Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/12269467b2fc006f8fce4cd6c3acfa77491dd0777d2a747415f28ccc8c60/watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2bcdc54ea267fe72bfc7d83c041e4eb58d7d8dc6f578dfddb52f037ce62f432", size = 451584, upload-time = "2025-06-15T19:06:51.834Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d3/254cea30f918f489db09d6a8435a7de7047f8cb68584477a515f160541d6/watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:923fec6e5461c42bd7e3fd5ec37492c6f3468be0499bc0707b4bbbc16ac21792", size = 454009, upload-time = "2025-06-15T19:06:52.896Z" }, ] [[package]] name = "websockets" -version = "15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2e/7a/8bc4d15af7ff30f7ba34f9a172063bfcee9f5001d7cef04bee800a658f33/websockets-15.0.tar.gz", hash = "sha256:ca36151289a15b39d8d683fd8b7abbe26fc50be311066c5f8dcf3cb8cee107ab", size = 175574, upload-time = "2025-02-16T11:06:55.664Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/16/81a7403c8c0a33383de647e89c07824ea6a654e3877d6ff402cbae298cb8/websockets-15.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dd24c4d256558429aeeb8d6c24ebad4e982ac52c50bc3670ae8646c181263965", size = 174702, upload-time = "2025-02-16T11:05:14.163Z" }, - { url = "https://files.pythonhosted.org/packages/ef/40/4629202386a3bf1195db9fe41baeb1d6dfd8d72e651d9592d81dae7fdc7c/websockets-15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f83eca8cbfd168e424dfa3b3b5c955d6c281e8fc09feb9d870886ff8d03683c7", size = 172359, upload-time = "2025-02-16T11:05:15.613Z" }, - { url = "https://files.pythonhosted.org/packages/7b/33/dfb650e822bc7912d8c542c452497867af91dec81e7b5bf96aca5b419d58/websockets-15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4095a1f2093002c2208becf6f9a178b336b7572512ee0a1179731acb7788e8ad", size = 172604, upload-time = "2025-02-16T11:05:17.855Z" }, - { url = "https://files.pythonhosted.org/packages/2e/52/666743114513fcffd43ee5df261a1eb5d41f8e9861b7a190b730732c19ba/websockets-15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb915101dfbf318486364ce85662bb7b020840f68138014972c08331458d41f3", size = 182145, upload-time = "2025-02-16T11:05:19.186Z" }, - { url = "https://files.pythonhosted.org/packages/9c/63/5273f146b13aa4a057a95ab0855d9990f3a1ced63693f4365135d1abfacc/websockets-15.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:45d464622314973d78f364689d5dbb9144e559f93dca11b11af3f2480b5034e1", size = 181152, upload-time = "2025-02-16T11:05:21.319Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ae/075697f3f97de7c26b73ae96d952e13fa36393e0db3f028540b28954e0a9/websockets-15.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ace960769d60037ca9625b4c578a6f28a14301bd2a1ff13bb00e824ac9f73e55", size = 181523, upload-time = "2025-02-16T11:05:22.805Z" }, - { url = "https://files.pythonhosted.org/packages/25/87/06d091bbcbe01903bed3dad3bb4a1a3c516f61e611ec31fffb28abe4974b/websockets-15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7cd4b1015d2f60dfe539ee6c95bc968d5d5fad92ab01bb5501a77393da4f596", size = 181791, upload-time = "2025-02-16T11:05:25.031Z" }, - { url = "https://files.pythonhosted.org/packages/77/08/5063b6cc1b2aa1fba2ee3b578b777db22fde7145f121d07fd878811e983b/websockets-15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4f7290295794b5dec470867c7baa4a14182b9732603fd0caf2a5bf1dc3ccabf3", size = 181231, upload-time = "2025-02-16T11:05:26.306Z" }, - { url = "https://files.pythonhosted.org/packages/86/ff/af23084df0a7405bb2add12add8c17d6192a8de9480f1b90d12352ba2b7d/websockets-15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3abd670ca7ce230d5a624fd3d55e055215d8d9b723adee0a348352f5d8d12ff4", size = 181191, upload-time = "2025-02-16T11:05:27.722Z" }, - { url = "https://files.pythonhosted.org/packages/21/ce/b2bdfcf49201dee0b899edc6a814755763ec03d74f2714923d38453a9e8d/websockets-15.0-cp311-cp311-win32.whl", hash = "sha256:110a847085246ab8d4d119632145224d6b49e406c64f1bbeed45c6f05097b680", size = 175666, upload-time = "2025-02-16T11:05:30.048Z" }, - { url = "https://files.pythonhosted.org/packages/8d/7b/444edcd5365538c226b631897975a65bbf5ccf27c77102e17d8f12a306ea/websockets-15.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7bbbe2cd6ed80aceef2a14e9f1c1b61683194c216472ed5ff33b700e784e37", size = 176105, upload-time = "2025-02-16T11:05:31.406Z" }, - { url = "https://files.pythonhosted.org/packages/22/1e/92c4547d7b2a93f848aedaf37e9054111bc00dc11bff4385ca3f80dbb412/websockets-15.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cccc18077acd34c8072578394ec79563664b1c205f7a86a62e94fafc7b59001f", size = 174709, upload-time = "2025-02-16T11:05:32.816Z" }, - { url = "https://files.pythonhosted.org/packages/9f/37/eae4830a28061ba552516d84478686b637cd9e57d6a90b45ad69e89cb0af/websockets-15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4c22992e24f12de340ca5f824121a5b3e1a37ad4360b4e1aaf15e9d1c42582d", size = 172372, upload-time = "2025-02-16T11:05:35.342Z" }, - { url = "https://files.pythonhosted.org/packages/46/2f/b409f8b8aa9328d5a47f7a301a43319d540d70cf036d1e6443675978a988/websockets-15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1206432cc6c644f6fc03374b264c5ff805d980311563202ed7fef91a38906276", size = 172607, upload-time = "2025-02-16T11:05:36.704Z" }, - { url = "https://files.pythonhosted.org/packages/d6/81/d7e2e4542d4b4df849b0110df1b1f94f2647b71ab4b65d672090931ad2bb/websockets-15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d3cc75ef3e17490042c47e0523aee1bcc4eacd2482796107fd59dd1100a44bc", size = 182422, upload-time = "2025-02-16T11:05:38.05Z" }, - { url = "https://files.pythonhosted.org/packages/b6/91/3b303160938d123eea97f58be363f7dbec76e8c59d587e07b5bc257dd584/websockets-15.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b89504227a5311610e4be16071465885a0a3d6b0e82e305ef46d9b064ce5fb72", size = 181362, upload-time = "2025-02-16T11:05:40.346Z" }, - { url = "https://files.pythonhosted.org/packages/f2/8b/df6807f1ca339c567aba9a7ab03bfdb9a833f625e8d2b4fc7529e4c701de/websockets-15.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56e3efe356416bc67a8e093607315951d76910f03d2b3ad49c4ade9207bf710d", size = 181787, upload-time = "2025-02-16T11:05:42.61Z" }, - { url = "https://files.pythonhosted.org/packages/21/37/e6d3d5ebb0ebcaf98ae84904205c9dcaf3e0fe93e65000b9f08631ed7309/websockets-15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f2205cdb444a42a7919690238fb5979a05439b9dbb73dd47c863d39640d85ab", size = 182058, upload-time = "2025-02-16T11:05:45.126Z" }, - { url = "https://files.pythonhosted.org/packages/c9/df/6aca296f2be4c638ad20908bb3d7c94ce7afc8d9b4b2b0780d1fc59b359c/websockets-15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:aea01f40995fa0945c020228ab919b8dfc93fc8a9f2d3d705ab5b793f32d9e99", size = 181434, upload-time = "2025-02-16T11:05:46.692Z" }, - { url = "https://files.pythonhosted.org/packages/88/f1/75717a982bab39bbe63c83f9df0e7753e5c98bab907eb4fb5d97fe5c8c11/websockets-15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a9f8e33747b1332db11cf7fcf4a9512bef9748cb5eb4d3f7fbc8c30d75dc6ffc", size = 181431, upload-time = "2025-02-16T11:05:48.194Z" }, - { url = "https://files.pythonhosted.org/packages/e7/15/cee9e63ed9ac5bfc1a3ae8fc6c02c41745023c21eed622eef142d8fdd749/websockets-15.0-cp312-cp312-win32.whl", hash = "sha256:32e02a2d83f4954aa8c17e03fe8ec6962432c39aca4be7e8ee346b05a3476904", size = 175678, upload-time = "2025-02-16T11:05:49.592Z" }, - { url = "https://files.pythonhosted.org/packages/4e/00/993974c60f40faabb725d4dbae8b072ef73b4c4454bd261d3b1d34ace41f/websockets-15.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc02b159b65c05f2ed9ec176b715b66918a674bd4daed48a9a7a590dd4be1aa", size = 176119, upload-time = "2025-02-16T11:05:51.926Z" }, - { url = "https://files.pythonhosted.org/packages/12/23/be28dc1023707ac51768f848d28a946443041a348ee3a54abdf9f6283372/websockets-15.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d2244d8ab24374bed366f9ff206e2619345f9cd7fe79aad5225f53faac28b6b1", size = 174714, upload-time = "2025-02-16T11:05:53.236Z" }, - { url = "https://files.pythonhosted.org/packages/8f/ff/02b5e9fbb078e7666bf3d25c18c69b499747a12f3e7f2776063ef3fb7061/websockets-15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3a302241fbe825a3e4fe07666a2ab513edfdc6d43ce24b79691b45115273b5e7", size = 172374, upload-time = "2025-02-16T11:05:55.551Z" }, - { url = "https://files.pythonhosted.org/packages/8e/61/901c8d4698e0477eff4c3c664d53f898b601fa83af4ce81946650ec2a4cb/websockets-15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:10552fed076757a70ba2c18edcbc601c7637b30cdfe8c24b65171e824c7d6081", size = 172605, upload-time = "2025-02-16T11:05:57.613Z" }, - { url = "https://files.pythonhosted.org/packages/d2/4b/dc47601a80dff317aecf8da7b4ab278d11d3494b2c373b493e4887561f90/websockets-15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c53f97032b87a406044a1c33d1e9290cc38b117a8062e8a8b285175d7e2f99c9", size = 182380, upload-time = "2025-02-16T11:05:58.984Z" }, - { url = "https://files.pythonhosted.org/packages/83/f7/b155d2b38f05ed47a0b8de1c9ea245fcd7fc625d89f35a37eccba34b42de/websockets-15.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1caf951110ca757b8ad9c4974f5cac7b8413004d2f29707e4d03a65d54cedf2b", size = 181325, upload-time = "2025-02-16T11:06:01.381Z" }, - { url = "https://files.pythonhosted.org/packages/d3/ff/040a20c01c294695cac0e361caf86f33347acc38f164f6d2be1d3e007d9f/websockets-15.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bf1ab71f9f23b0a1d52ec1682a3907e0c208c12fef9c3e99d2b80166b17905f", size = 181763, upload-time = "2025-02-16T11:06:04.344Z" }, - { url = "https://files.pythonhosted.org/packages/cb/6a/af23e93678fda8341ac8775e85123425e45c608389d3514863c702896ea5/websockets-15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bfcd3acc1a81f106abac6afd42327d2cf1e77ec905ae11dc1d9142a006a496b6", size = 182097, upload-time = "2025-02-16T11:06:05.722Z" }, - { url = "https://files.pythonhosted.org/packages/7e/3e/1069e159c30129dc03c01513b5830237e576f47cedb888777dd885cae583/websockets-15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c8c5c8e1bac05ef3c23722e591ef4f688f528235e2480f157a9cfe0a19081375", size = 181485, upload-time = "2025-02-16T11:06:07.076Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a7/c91c47103f1cd941b576bbc452601e9e01f67d5c9be3e0a9abe726491ab5/websockets-15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:86bfb52a9cfbcc09aba2b71388b0a20ea5c52b6517c0b2e316222435a8cdab72", size = 181466, upload-time = "2025-02-16T11:06:08.927Z" }, - { url = "https://files.pythonhosted.org/packages/16/32/a4ca6e3d56c24aac46b0cf5c03b841379f6409d07fc2044b244f90f54105/websockets-15.0-cp313-cp313-win32.whl", hash = "sha256:26ba70fed190708551c19a360f9d7eca8e8c0f615d19a574292b7229e0ae324c", size = 175673, upload-time = "2025-02-16T11:06:11.188Z" }, - { url = "https://files.pythonhosted.org/packages/c0/31/25a417a23e985b61ffa5544f9facfe4a118cb64d664c886f1244a8baeca5/websockets-15.0-cp313-cp313-win_amd64.whl", hash = "sha256:ae721bcc8e69846af00b7a77a220614d9b2ec57d25017a6bbde3a99473e41ce8", size = 176115, upload-time = "2025-02-16T11:06:12.602Z" }, - { url = "https://files.pythonhosted.org/packages/e8/b2/31eec524b53f01cd8343f10a8e429730c52c1849941d1f530f8253b6d934/websockets-15.0-py3-none-any.whl", hash = "sha256:51ffd53c53c4442415b613497a34ba0aa7b99ac07f1e4a62db5dcd640ae6c3c3", size = 169023, upload-time = "2025-02-16T11:06:53.32Z" }, +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { 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 = "yarl" -version = "1.18.3" +version = "1.20.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b7/9d/4b94a8e6d2b51b599516a5cb88e5bc99b4d8d4583e468057eaa29d5f0918/yarl-1.18.3.tar.gz", hash = "sha256:ac1801c45cbf77b6c99242eeff4fffb5e4e73a800b5c4ad4fc0be5def634d2e1", size = 181062, upload-time = "2024-12-01T20:35:23.292Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/40/93/282b5f4898d8e8efaf0790ba6d10e2245d2c9f30e199d1a85cae9356098c/yarl-1.18.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8503ad47387b8ebd39cbbbdf0bf113e17330ffd339ba1144074da24c545f0069", size = 141555, upload-time = "2024-12-01T20:33:08.819Z" }, - { url = "https://files.pythonhosted.org/packages/6d/9c/0a49af78df099c283ca3444560f10718fadb8a18dc8b3edf8c7bd9fd7d89/yarl-1.18.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:02ddb6756f8f4517a2d5e99d8b2f272488e18dd0bfbc802f31c16c6c20f22193", size = 94351, upload-time = "2024-12-01T20:33:10.609Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a1/205ab51e148fdcedad189ca8dd587794c6f119882437d04c33c01a75dece/yarl-1.18.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:67a283dd2882ac98cc6318384f565bffc751ab564605959df4752d42483ad889", size = 92286, upload-time = "2024-12-01T20:33:12.322Z" }, - { url = "https://files.pythonhosted.org/packages/ed/fe/88b690b30f3f59275fb674f5f93ddd4a3ae796c2b62e5bb9ece8a4914b83/yarl-1.18.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d980e0325b6eddc81331d3f4551e2a333999fb176fd153e075c6d1c2530aa8a8", size = 340649, upload-time = "2024-12-01T20:33:13.842Z" }, - { url = "https://files.pythonhosted.org/packages/07/eb/3b65499b568e01f36e847cebdc8d7ccb51fff716dbda1ae83c3cbb8ca1c9/yarl-1.18.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b643562c12680b01e17239be267bc306bbc6aac1f34f6444d1bded0c5ce438ca", size = 356623, upload-time = "2024-12-01T20:33:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/33/46/f559dc184280b745fc76ec6b1954de2c55595f0ec0a7614238b9ebf69618/yarl-1.18.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c017a3b6df3a1bd45b9fa49a0f54005e53fbcad16633870104b66fa1a30a29d8", size = 354007, upload-time = "2024-12-01T20:33:17.518Z" }, - { url = "https://files.pythonhosted.org/packages/af/ba/1865d85212351ad160f19fb99808acf23aab9a0f8ff31c8c9f1b4d671fc9/yarl-1.18.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75674776d96d7b851b6498f17824ba17849d790a44d282929c42dbb77d4f17ae", size = 344145, upload-time = "2024-12-01T20:33:20.071Z" }, - { url = "https://files.pythonhosted.org/packages/94/cb/5c3e975d77755d7b3d5193e92056b19d83752ea2da7ab394e22260a7b824/yarl-1.18.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ccaa3a4b521b780a7e771cc336a2dba389a0861592bbce09a476190bb0c8b4b3", size = 336133, upload-time = "2024-12-01T20:33:22.515Z" }, - { url = "https://files.pythonhosted.org/packages/19/89/b77d3fd249ab52a5c40859815765d35c91425b6bb82e7427ab2f78f5ff55/yarl-1.18.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2d06d3005e668744e11ed80812e61efd77d70bb7f03e33c1598c301eea20efbb", size = 347967, upload-time = "2024-12-01T20:33:24.139Z" }, - { url = "https://files.pythonhosted.org/packages/35/bd/f6b7630ba2cc06c319c3235634c582a6ab014d52311e7d7c22f9518189b5/yarl-1.18.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:9d41beda9dc97ca9ab0b9888cb71f7539124bc05df02c0cff6e5acc5a19dcc6e", size = 346397, upload-time = "2024-12-01T20:33:26.205Z" }, - { url = "https://files.pythonhosted.org/packages/18/1a/0b4e367d5a72d1f095318344848e93ea70da728118221f84f1bf6c1e39e7/yarl-1.18.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ba23302c0c61a9999784e73809427c9dbedd79f66a13d84ad1b1943802eaaf59", size = 350206, upload-time = "2024-12-01T20:33:27.83Z" }, - { url = "https://files.pythonhosted.org/packages/b5/cf/320fff4367341fb77809a2d8d7fe75b5d323a8e1b35710aafe41fdbf327b/yarl-1.18.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6748dbf9bfa5ba1afcc7556b71cda0d7ce5f24768043a02a58846e4a443d808d", size = 362089, upload-time = "2024-12-01T20:33:29.565Z" }, - { url = "https://files.pythonhosted.org/packages/57/cf/aadba261d8b920253204085268bad5e8cdd86b50162fcb1b10c10834885a/yarl-1.18.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0b0cad37311123211dc91eadcb322ef4d4a66008d3e1bdc404808992260e1a0e", size = 366267, upload-time = "2024-12-01T20:33:31.449Z" }, - { url = "https://files.pythonhosted.org/packages/54/58/fb4cadd81acdee6dafe14abeb258f876e4dd410518099ae9a35c88d8097c/yarl-1.18.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0fb2171a4486bb075316ee754c6d8382ea6eb8b399d4ec62fde2b591f879778a", size = 359141, upload-time = "2024-12-01T20:33:33.79Z" }, - { url = "https://files.pythonhosted.org/packages/9a/7a/4c571597589da4cd5c14ed2a0b17ac56ec9ee7ee615013f74653169e702d/yarl-1.18.3-cp311-cp311-win32.whl", hash = "sha256:61b1a825a13bef4a5f10b1885245377d3cd0bf87cba068e1d9a88c2ae36880e1", size = 84402, upload-time = "2024-12-01T20:33:35.689Z" }, - { url = "https://files.pythonhosted.org/packages/ae/7b/8600250b3d89b625f1121d897062f629883c2f45339623b69b1747ec65fa/yarl-1.18.3-cp311-cp311-win_amd64.whl", hash = "sha256:b9d60031cf568c627d028239693fd718025719c02c9f55df0a53e587aab951b5", size = 91030, upload-time = "2024-12-01T20:33:37.511Z" }, - { url = "https://files.pythonhosted.org/packages/33/85/bd2e2729752ff4c77338e0102914897512e92496375e079ce0150a6dc306/yarl-1.18.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1dd4bdd05407ced96fed3d7f25dbbf88d2ffb045a0db60dbc247f5b3c5c25d50", size = 142644, upload-time = "2024-12-01T20:33:39.204Z" }, - { url = "https://files.pythonhosted.org/packages/ff/74/1178322cc0f10288d7eefa6e4a85d8d2e28187ccab13d5b844e8b5d7c88d/yarl-1.18.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7c33dd1931a95e5d9a772d0ac5e44cac8957eaf58e3c8da8c1414de7dd27c576", size = 94962, upload-time = "2024-12-01T20:33:40.808Z" }, - { url = "https://files.pythonhosted.org/packages/be/75/79c6acc0261e2c2ae8a1c41cf12265e91628c8c58ae91f5ff59e29c0787f/yarl-1.18.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25b411eddcfd56a2f0cd6a384e9f4f7aa3efee14b188de13048c25b5e91f1640", size = 92795, upload-time = "2024-12-01T20:33:42.322Z" }, - { url = "https://files.pythonhosted.org/packages/6b/32/927b2d67a412c31199e83fefdce6e645247b4fb164aa1ecb35a0f9eb2058/yarl-1.18.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:436c4fc0a4d66b2badc6c5fc5ef4e47bb10e4fd9bf0c79524ac719a01f3607c2", size = 332368, upload-time = "2024-12-01T20:33:43.956Z" }, - { url = "https://files.pythonhosted.org/packages/19/e5/859fca07169d6eceeaa4fde1997c91d8abde4e9a7c018e371640c2da2b71/yarl-1.18.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e35ef8683211db69ffe129a25d5634319a677570ab6b2eba4afa860f54eeaf75", size = 342314, upload-time = "2024-12-01T20:33:46.046Z" }, - { url = "https://files.pythonhosted.org/packages/08/75/76b63ccd91c9e03ab213ef27ae6add2e3400e77e5cdddf8ed2dbc36e3f21/yarl-1.18.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:84b2deecba4a3f1a398df819151eb72d29bfeb3b69abb145a00ddc8d30094512", size = 341987, upload-time = "2024-12-01T20:33:48.352Z" }, - { url = "https://files.pythonhosted.org/packages/1a/e1/a097d5755d3ea8479a42856f51d97eeff7a3a7160593332d98f2709b3580/yarl-1.18.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00e5a1fea0fd4f5bfa7440a47eff01d9822a65b4488f7cff83155a0f31a2ecba", size = 336914, upload-time = "2024-12-01T20:33:50.875Z" }, - { url = "https://files.pythonhosted.org/packages/0b/42/e1b4d0e396b7987feceebe565286c27bc085bf07d61a59508cdaf2d45e63/yarl-1.18.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d0e883008013c0e4aef84dcfe2a0b172c4d23c2669412cf5b3371003941f72bb", size = 325765, upload-time = "2024-12-01T20:33:52.641Z" }, - { url = "https://files.pythonhosted.org/packages/7e/18/03a5834ccc9177f97ca1bbb245b93c13e58e8225276f01eedc4cc98ab820/yarl-1.18.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5a3f356548e34a70b0172d8890006c37be92995f62d95a07b4a42e90fba54272", size = 344444, upload-time = "2024-12-01T20:33:54.395Z" }, - { url = "https://files.pythonhosted.org/packages/c8/03/a713633bdde0640b0472aa197b5b86e90fbc4c5bc05b727b714cd8a40e6d/yarl-1.18.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ccd17349166b1bee6e529b4add61727d3f55edb7babbe4069b5764c9587a8cc6", size = 340760, upload-time = "2024-12-01T20:33:56.286Z" }, - { url = "https://files.pythonhosted.org/packages/eb/99/f6567e3f3bbad8fd101886ea0276c68ecb86a2b58be0f64077396cd4b95e/yarl-1.18.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b958ddd075ddba5b09bb0be8a6d9906d2ce933aee81100db289badbeb966f54e", size = 346484, upload-time = "2024-12-01T20:33:58.375Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a9/84717c896b2fc6cb15bd4eecd64e34a2f0a9fd6669e69170c73a8b46795a/yarl-1.18.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c7d79f7d9aabd6011004e33b22bc13056a3e3fb54794d138af57f5ee9d9032cb", size = 359864, upload-time = "2024-12-01T20:34:00.22Z" }, - { url = "https://files.pythonhosted.org/packages/1e/2e/d0f5f1bef7ee93ed17e739ec8dbcb47794af891f7d165fa6014517b48169/yarl-1.18.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4891ed92157e5430874dad17b15eb1fda57627710756c27422200c52d8a4e393", size = 364537, upload-time = "2024-12-01T20:34:03.54Z" }, - { url = "https://files.pythonhosted.org/packages/97/8a/568d07c5d4964da5b02621a517532adb8ec5ba181ad1687191fffeda0ab6/yarl-1.18.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ce1af883b94304f493698b00d0f006d56aea98aeb49d75ec7d98cd4a777e9285", size = 357861, upload-time = "2024-12-01T20:34:05.73Z" }, - { url = "https://files.pythonhosted.org/packages/7d/e3/924c3f64b6b3077889df9a1ece1ed8947e7b61b0a933f2ec93041990a677/yarl-1.18.3-cp312-cp312-win32.whl", hash = "sha256:f91c4803173928a25e1a55b943c81f55b8872f0018be83e3ad4938adffb77dd2", size = 84097, upload-time = "2024-12-01T20:34:07.664Z" }, - { url = "https://files.pythonhosted.org/packages/34/45/0e055320daaabfc169b21ff6174567b2c910c45617b0d79c68d7ab349b02/yarl-1.18.3-cp312-cp312-win_amd64.whl", hash = "sha256:7e2ee16578af3b52ac2f334c3b1f92262f47e02cc6193c598502bd46f5cd1477", size = 90399, upload-time = "2024-12-01T20:34:09.61Z" }, - { url = "https://files.pythonhosted.org/packages/30/c7/c790513d5328a8390be8f47be5d52e141f78b66c6c48f48d241ca6bd5265/yarl-1.18.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:90adb47ad432332d4f0bc28f83a5963f426ce9a1a8809f5e584e704b82685dcb", size = 140789, upload-time = "2024-12-01T20:34:11.414Z" }, - { url = "https://files.pythonhosted.org/packages/30/aa/a2f84e93554a578463e2edaaf2300faa61c8701f0898725842c704ba5444/yarl-1.18.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:913829534200eb0f789d45349e55203a091f45c37a2674678744ae52fae23efa", size = 94144, upload-time = "2024-12-01T20:34:13.485Z" }, - { url = "https://files.pythonhosted.org/packages/c6/fc/d68d8f83714b221a85ce7866832cba36d7c04a68fa6a960b908c2c84f325/yarl-1.18.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ef9f7768395923c3039055c14334ba4d926f3baf7b776c923c93d80195624782", size = 91974, upload-time = "2024-12-01T20:34:15.234Z" }, - { url = "https://files.pythonhosted.org/packages/56/4e/d2563d8323a7e9a414b5b25341b3942af5902a2263d36d20fb17c40411e2/yarl-1.18.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88a19f62ff30117e706ebc9090b8ecc79aeb77d0b1f5ec10d2d27a12bc9f66d0", size = 333587, upload-time = "2024-12-01T20:34:17.358Z" }, - { url = "https://files.pythonhosted.org/packages/25/c9/cfec0bc0cac8d054be223e9f2c7909d3e8442a856af9dbce7e3442a8ec8d/yarl-1.18.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e17c9361d46a4d5addf777c6dd5eab0715a7684c2f11b88c67ac37edfba6c482", size = 344386, upload-time = "2024-12-01T20:34:19.842Z" }, - { url = "https://files.pythonhosted.org/packages/ab/5d/4c532190113b25f1364d25f4c319322e86232d69175b91f27e3ebc2caf9a/yarl-1.18.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a74a13a4c857a84a845505fd2d68e54826a2cd01935a96efb1e9d86c728e186", size = 345421, upload-time = "2024-12-01T20:34:21.975Z" }, - { url = "https://files.pythonhosted.org/packages/23/d1/6cdd1632da013aa6ba18cee4d750d953104a5e7aac44e249d9410a972bf5/yarl-1.18.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41f7ce59d6ee7741af71d82020346af364949314ed3d87553763a2df1829cc58", size = 339384, upload-time = "2024-12-01T20:34:24.717Z" }, - { url = "https://files.pythonhosted.org/packages/9a/c4/6b3c39bec352e441bd30f432cda6ba51681ab19bb8abe023f0d19777aad1/yarl-1.18.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f52a265001d830bc425f82ca9eabda94a64a4d753b07d623a9f2863fde532b53", size = 326689, upload-time = "2024-12-01T20:34:26.886Z" }, - { url = "https://files.pythonhosted.org/packages/23/30/07fb088f2eefdc0aa4fc1af4e3ca4eb1a3aadd1ce7d866d74c0f124e6a85/yarl-1.18.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:82123d0c954dc58db301f5021a01854a85bf1f3bb7d12ae0c01afc414a882ca2", size = 345453, upload-time = "2024-12-01T20:34:29.605Z" }, - { url = "https://files.pythonhosted.org/packages/63/09/d54befb48f9cd8eec43797f624ec37783a0266855f4930a91e3d5c7717f8/yarl-1.18.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2ec9bbba33b2d00999af4631a3397d1fd78290c48e2a3e52d8dd72db3a067ac8", size = 341872, upload-time = "2024-12-01T20:34:31.454Z" }, - { url = "https://files.pythonhosted.org/packages/91/26/fd0ef9bf29dd906a84b59f0cd1281e65b0c3e08c6aa94b57f7d11f593518/yarl-1.18.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fbd6748e8ab9b41171bb95c6142faf068f5ef1511935a0aa07025438dd9a9bc1", size = 347497, upload-time = "2024-12-01T20:34:34.004Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b5/14ac7a256d0511b2ac168d50d4b7d744aea1c1aa20c79f620d1059aab8b2/yarl-1.18.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:877d209b6aebeb5b16c42cbb377f5f94d9e556626b1bfff66d7b0d115be88d0a", size = 359981, upload-time = "2024-12-01T20:34:36.624Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b3/d493221ad5cbd18bc07e642894030437e405e1413c4236dd5db6e46bcec9/yarl-1.18.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b464c4ab4bfcb41e3bfd3f1c26600d038376c2de3297760dfe064d2cb7ea8e10", size = 366229, upload-time = "2024-12-01T20:34:38.657Z" }, - { url = "https://files.pythonhosted.org/packages/04/56/6a3e2a5d9152c56c346df9b8fb8edd2c8888b1e03f96324d457e5cf06d34/yarl-1.18.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8d39d351e7faf01483cc7ff7c0213c412e38e5a340238826be7e0e4da450fdc8", size = 360383, upload-time = "2024-12-01T20:34:40.501Z" }, - { url = "https://files.pythonhosted.org/packages/fd/b7/4b3c7c7913a278d445cc6284e59b2e62fa25e72758f888b7a7a39eb8423f/yarl-1.18.3-cp313-cp313-win32.whl", hash = "sha256:61ee62ead9b68b9123ec24bc866cbef297dd266175d53296e2db5e7f797f902d", size = 310152, upload-time = "2024-12-01T20:34:42.814Z" }, - { url = "https://files.pythonhosted.org/packages/f5/d5/688db678e987c3e0fb17867970700b92603cadf36c56e5fb08f23e822a0c/yarl-1.18.3-cp313-cp313-win_amd64.whl", hash = "sha256:578e281c393af575879990861823ef19d66e2b1d0098414855dd367e234f5b3c", size = 315723, upload-time = "2024-12-01T20:34:44.699Z" }, - { url = "https://files.pythonhosted.org/packages/f5/4b/a06e0ec3d155924f77835ed2d167ebd3b211a7b0853da1cf8d8414d784ef/yarl-1.18.3-py3-none-any.whl", hash = "sha256:b57f4f58099328dfb26c6a771d09fb20dbbae81d20cfb66141251ea063bd101b", size = 45109, upload-time = "2024-12-01T20:35:20.834Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428, upload-time = "2025-06-10T00:46:09.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/18/893b50efc2350e47a874c5c2d67e55a0ea5df91186b2a6f5ac52eff887cd/yarl-1.20.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:47ee6188fea634bdfaeb2cc420f5b3b17332e6225ce88149a17c413c77ff269e", size = 133833, upload-time = "2025-06-10T00:43:07.393Z" }, + { url = "https://files.pythonhosted.org/packages/89/ed/b8773448030e6fc47fa797f099ab9eab151a43a25717f9ac043844ad5ea3/yarl-1.20.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0f6500f69e8402d513e5eedb77a4e1818691e8f45e6b687147963514d84b44b", size = 91070, upload-time = "2025-06-10T00:43:09.538Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e3/409bd17b1e42619bf69f60e4f031ce1ccb29bd7380117a55529e76933464/yarl-1.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a8900a42fcdaad568de58887c7b2f602962356908eedb7628eaf6021a6e435b", size = 89818, upload-time = "2025-06-10T00:43:11.575Z" }, + { url = "https://files.pythonhosted.org/packages/f8/77/64d8431a4d77c856eb2d82aa3de2ad6741365245a29b3a9543cd598ed8c5/yarl-1.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bad6d131fda8ef508b36be3ece16d0902e80b88ea7200f030a0f6c11d9e508d4", size = 347003, upload-time = "2025-06-10T00:43:14.088Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d2/0c7e4def093dcef0bd9fa22d4d24b023788b0a33b8d0088b51aa51e21e99/yarl-1.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:df018d92fe22aaebb679a7f89fe0c0f368ec497e3dda6cb81a567610f04501f1", size = 336537, upload-time = "2025-06-10T00:43:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f3/fc514f4b2cf02cb59d10cbfe228691d25929ce8f72a38db07d3febc3f706/yarl-1.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f969afbb0a9b63c18d0feecf0db09d164b7a44a053e78a7d05f5df163e43833", size = 362358, upload-time = "2025-06-10T00:43:18.704Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6d/a313ac8d8391381ff9006ac05f1d4331cee3b1efaa833a53d12253733255/yarl-1.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:812303eb4aa98e302886ccda58d6b099e3576b1b9276161469c25803a8db277d", size = 357362, upload-time = "2025-06-10T00:43:20.888Z" }, + { url = "https://files.pythonhosted.org/packages/00/70/8f78a95d6935a70263d46caa3dd18e1f223cf2f2ff2037baa01a22bc5b22/yarl-1.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98c4a7d166635147924aa0bf9bfe8d8abad6fffa6102de9c99ea04a1376f91e8", size = 348979, upload-time = "2025-06-10T00:43:23.169Z" }, + { url = "https://files.pythonhosted.org/packages/cb/05/42773027968968f4f15143553970ee36ead27038d627f457cc44bbbeecf3/yarl-1.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12e768f966538e81e6e7550f9086a6236b16e26cd964cf4df35349970f3551cf", size = 337274, upload-time = "2025-06-10T00:43:27.111Z" }, + { url = "https://files.pythonhosted.org/packages/05/be/665634aa196954156741ea591d2f946f1b78ceee8bb8f28488bf28c0dd62/yarl-1.20.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe41919b9d899661c5c28a8b4b0acf704510b88f27f0934ac7a7bebdd8938d5e", size = 363294, upload-time = "2025-06-10T00:43:28.96Z" }, + { url = "https://files.pythonhosted.org/packages/eb/90/73448401d36fa4e210ece5579895731f190d5119c4b66b43b52182e88cd5/yarl-1.20.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8601bc010d1d7780592f3fc1bdc6c72e2b6466ea34569778422943e1a1f3c389", size = 358169, upload-time = "2025-06-10T00:43:30.701Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b0/fce922d46dc1eb43c811f1889f7daa6001b27a4005587e94878570300881/yarl-1.20.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:daadbdc1f2a9033a2399c42646fbd46da7992e868a5fe9513860122d7fe7a73f", size = 362776, upload-time = "2025-06-10T00:43:32.51Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0d/b172628fce039dae8977fd22caeff3eeebffd52e86060413f5673767c427/yarl-1.20.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:03aa1e041727cb438ca762628109ef1333498b122e4c76dd858d186a37cec845", size = 381341, upload-time = "2025-06-10T00:43:34.543Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9b/5b886d7671f4580209e855974fe1cecec409aa4a89ea58b8f0560dc529b1/yarl-1.20.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:642980ef5e0fa1de5fa96d905c7e00cb2c47cb468bfcac5a18c58e27dbf8d8d1", size = 379988, upload-time = "2025-06-10T00:43:36.489Z" }, + { url = "https://files.pythonhosted.org/packages/73/be/75ef5fd0fcd8f083a5d13f78fd3f009528132a1f2a1d7c925c39fa20aa79/yarl-1.20.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:86971e2795584fe8c002356d3b97ef6c61862720eeff03db2a7c86b678d85b3e", size = 371113, upload-time = "2025-06-10T00:43:38.592Z" }, + { url = "https://files.pythonhosted.org/packages/50/4f/62faab3b479dfdcb741fe9e3f0323e2a7d5cd1ab2edc73221d57ad4834b2/yarl-1.20.1-cp311-cp311-win32.whl", hash = "sha256:597f40615b8d25812f14562699e287f0dcc035d25eb74da72cae043bb884d773", size = 81485, upload-time = "2025-06-10T00:43:41.038Z" }, + { url = "https://files.pythonhosted.org/packages/f0/09/d9c7942f8f05c32ec72cd5c8e041c8b29b5807328b68b4801ff2511d4d5e/yarl-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:26ef53a9e726e61e9cd1cda6b478f17e350fb5800b4bd1cd9fe81c4d91cfeb2e", size = 86686, upload-time = "2025-06-10T00:43:42.692Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9a/cb7fad7d73c69f296eda6815e4a2c7ed53fc70c2f136479a91c8e5fbdb6d/yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9", size = 133667, upload-time = "2025-06-10T00:43:44.369Z" }, + { url = "https://files.pythonhosted.org/packages/67/38/688577a1cb1e656e3971fb66a3492501c5a5df56d99722e57c98249e5b8a/yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a", size = 91025, upload-time = "2025-06-10T00:43:46.295Z" }, + { url = "https://files.pythonhosted.org/packages/50/ec/72991ae51febeb11a42813fc259f0d4c8e0507f2b74b5514618d8b640365/yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2", size = 89709, upload-time = "2025-06-10T00:43:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/99/da/4d798025490e89426e9f976702e5f9482005c548c579bdae792a4c37769e/yarl-1.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90bbd29c4fe234233f7fa2b9b121fb63c321830e5d05b45153a2ca68f7d310ee", size = 352287, upload-time = "2025-06-10T00:43:49.924Z" }, + { url = "https://files.pythonhosted.org/packages/1a/26/54a15c6a567aac1c61b18aa0f4b8aa2e285a52d547d1be8bf48abe2b3991/yarl-1.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:680e19c7ce3710ac4cd964e90dad99bf9b5029372ba0c7cbfcd55e54d90ea819", size = 345429, upload-time = "2025-06-10T00:43:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/d6/95/9dcf2386cb875b234353b93ec43e40219e14900e046bf6ac118f94b1e353/yarl-1.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a979218c1fdb4246a05efc2cc23859d47c89af463a90b99b7c56094daf25a16", size = 365429, upload-time = "2025-06-10T00:43:53.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/b2/33a8750f6a4bc224242a635f5f2cff6d6ad5ba651f6edcccf721992c21a0/yarl-1.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255b468adf57b4a7b65d8aad5b5138dce6a0752c139965711bdcb81bc370e1b6", size = 363862, upload-time = "2025-06-10T00:43:55.766Z" }, + { url = "https://files.pythonhosted.org/packages/98/28/3ab7acc5b51f4434b181b0cee8f1f4b77a65919700a355fb3617f9488874/yarl-1.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97d67108e79cfe22e2b430d80d7571ae57d19f17cda8bb967057ca8a7bf5bfd", size = 355616, upload-time = "2025-06-10T00:43:58.056Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f666894aa947a371724ec7cd2e5daa78ee8a777b21509b4252dd7bd15e29/yarl-1.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8570d998db4ddbfb9a590b185a0a33dbf8aafb831d07a5257b4ec9948df9cb0a", size = 339954, upload-time = "2025-06-10T00:43:59.773Z" }, + { url = "https://files.pythonhosted.org/packages/f1/81/5f466427e09773c04219d3450d7a1256138a010b6c9f0af2d48565e9ad13/yarl-1.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97c75596019baae7c71ccf1d8cc4738bc08134060d0adfcbe5642f778d1dca38", size = 365575, upload-time = "2025-06-10T00:44:02.051Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e3/e4b0ad8403e97e6c9972dd587388940a032f030ebec196ab81a3b8e94d31/yarl-1.20.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c48912653e63aef91ff988c5432832692ac5a1d8f0fb8a33091520b5bbe19ef", size = 365061, upload-time = "2025-06-10T00:44:04.196Z" }, + { url = "https://files.pythonhosted.org/packages/ac/99/b8a142e79eb86c926f9f06452eb13ecb1bb5713bd01dc0038faf5452e544/yarl-1.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c3ae28f3ae1563c50f3d37f064ddb1511ecc1d5584e88c6b7c63cf7702a6d5f", size = 364142, upload-time = "2025-06-10T00:44:06.527Z" }, + { url = "https://files.pythonhosted.org/packages/34/f2/08ed34a4a506d82a1a3e5bab99ccd930a040f9b6449e9fd050320e45845c/yarl-1.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e9642f27036283550f5f57dc6156c51084b458570b9d0d96100c8bebb186a8", size = 381894, upload-time = "2025-06-10T00:44:08.379Z" }, + { url = "https://files.pythonhosted.org/packages/92/f8/9a3fbf0968eac704f681726eff595dce9b49c8a25cd92bf83df209668285/yarl-1.20.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2c26b0c49220d5799f7b22c6838409ee9bc58ee5c95361a4d7831f03cc225b5a", size = 383378, upload-time = "2025-06-10T00:44:10.51Z" }, + { url = "https://files.pythonhosted.org/packages/af/85/9363f77bdfa1e4d690957cd39d192c4cacd1c58965df0470a4905253b54f/yarl-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564ab3d517e3d01c408c67f2e5247aad4019dcf1969982aba3974b4093279004", size = 374069, upload-time = "2025-06-10T00:44:12.834Z" }, + { url = "https://files.pythonhosted.org/packages/35/99/9918c8739ba271dcd935400cff8b32e3cd319eaf02fcd023d5dcd487a7c8/yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5", size = 81249, upload-time = "2025-06-10T00:44:14.731Z" }, + { url = "https://files.pythonhosted.org/packages/eb/83/5d9092950565481b413b31a23e75dd3418ff0a277d6e0abf3729d4d1ce25/yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698", size = 86710, upload-time = "2025-06-10T00:44:16.716Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e1/2411b6d7f769a07687acee88a062af5833cf1966b7266f3d8dfb3d3dc7d3/yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a", size = 131811, upload-time = "2025-06-10T00:44:18.933Z" }, + { url = "https://files.pythonhosted.org/packages/b2/27/584394e1cb76fb771371770eccad35de400e7b434ce3142c2dd27392c968/yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3", size = 90078, upload-time = "2025-06-10T00:44:20.635Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9a/3246ae92d4049099f52d9b0fe3486e3b500e29b7ea872d0f152966fc209d/yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7", size = 88748, upload-time = "2025-06-10T00:44:22.34Z" }, + { url = "https://files.pythonhosted.org/packages/a3/25/35afe384e31115a1a801fbcf84012d7a066d89035befae7c5d4284df1e03/yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691", size = 349595, upload-time = "2025-06-10T00:44:24.314Z" }, + { url = "https://files.pythonhosted.org/packages/28/2d/8aca6cb2cabc8f12efcb82749b9cefecbccfc7b0384e56cd71058ccee433/yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31", size = 342616, upload-time = "2025-06-10T00:44:26.167Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/1312633d16b31acf0098d30440ca855e3492d66623dafb8e25b03d00c3da/yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28", size = 361324, upload-time = "2025-06-10T00:44:27.915Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a0/688cc99463f12f7669eec7c8acc71ef56a1521b99eab7cd3abb75af887b0/yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653", size = 359676, upload-time = "2025-06-10T00:44:30.041Z" }, + { url = "https://files.pythonhosted.org/packages/af/44/46407d7f7a56e9a85a4c207724c9f2c545c060380718eea9088f222ba697/yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5", size = 352614, upload-time = "2025-06-10T00:44:32.171Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/31163295e82b8d5485d31d9cf7754d973d41915cadce070491778d9c9825/yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02", size = 336766, upload-time = "2025-06-10T00:44:34.494Z" }, + { url = "https://files.pythonhosted.org/packages/b4/8e/c41a5bc482121f51c083c4c2bcd16b9e01e1cf8729e380273a952513a21f/yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53", size = 364615, upload-time = "2025-06-10T00:44:36.856Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5b/61a3b054238d33d70ea06ebba7e58597891b71c699e247df35cc984ab393/yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc", size = 360982, upload-time = "2025-06-10T00:44:39.141Z" }, + { url = "https://files.pythonhosted.org/packages/df/a3/6a72fb83f8d478cb201d14927bc8040af901811a88e0ff2da7842dd0ed19/yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04", size = 369792, upload-time = "2025-06-10T00:44:40.934Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/4cc3c36dfc7c077f8dedb561eb21f69e1e9f2456b91b593882b0b18c19dc/yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4", size = 382049, upload-time = "2025-06-10T00:44:42.854Z" }, + { url = "https://files.pythonhosted.org/packages/19/3a/e54e2c4752160115183a66dc9ee75a153f81f3ab2ba4bf79c3c53b33de34/yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b", size = 384774, upload-time = "2025-06-10T00:44:45.275Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/200ae86dabfca89060ec6447649f219b4cbd94531e425e50d57e5f5ac330/yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1", size = 374252, upload-time = "2025-06-10T00:44:47.31Z" }, + { url = "https://files.pythonhosted.org/packages/83/75/11ee332f2f516b3d094e89448da73d557687f7d137d5a0f48c40ff211487/yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7", size = 81198, upload-time = "2025-06-10T00:44:49.164Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ba/39b1ecbf51620b40ab402b0fc817f0ff750f6d92712b44689c2c215be89d/yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c", size = 86346, upload-time = "2025-06-10T00:44:51.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/c7/669c52519dca4c95153c8ad96dd123c79f354a376346b198f438e56ffeb4/yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d", size = 138826, upload-time = "2025-06-10T00:44:52.883Z" }, + { url = "https://files.pythonhosted.org/packages/6a/42/fc0053719b44f6ad04a75d7f05e0e9674d45ef62f2d9ad2c1163e5c05827/yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf", size = 93217, upload-time = "2025-06-10T00:44:54.658Z" }, + { url = "https://files.pythonhosted.org/packages/4f/7f/fa59c4c27e2a076bba0d959386e26eba77eb52ea4a0aac48e3515c186b4c/yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3", size = 92700, upload-time = "2025-06-10T00:44:56.784Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/062b2f48e7c93481e88eff97a6312dca15ea200e959f23e96d8ab898c5b8/yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d", size = 347644, upload-time = "2025-06-10T00:44:59.071Z" }, + { url = "https://files.pythonhosted.org/packages/89/47/78b7f40d13c8f62b499cc702fdf69e090455518ae544c00a3bf4afc9fc77/yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c", size = 323452, upload-time = "2025-06-10T00:45:01.605Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2b/490d3b2dc66f52987d4ee0d3090a147ea67732ce6b4d61e362c1846d0d32/yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1", size = 346378, upload-time = "2025-06-10T00:45:03.946Z" }, + { url = "https://files.pythonhosted.org/packages/66/ad/775da9c8a94ce925d1537f939a4f17d782efef1f973039d821cbe4bcc211/yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce", size = 353261, upload-time = "2025-06-10T00:45:05.992Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/0ed0922b47a4f5c6eb9065d5ff1e459747226ddce5c6a4c111e728c9f701/yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3", size = 335987, upload-time = "2025-06-10T00:45:08.227Z" }, + { url = "https://files.pythonhosted.org/packages/3e/49/bc728a7fe7d0e9336e2b78f0958a2d6b288ba89f25a1762407a222bf53c3/yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be", size = 329361, upload-time = "2025-06-10T00:45:10.11Z" }, + { url = "https://files.pythonhosted.org/packages/93/8f/b811b9d1f617c83c907e7082a76e2b92b655400e61730cd61a1f67178393/yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16", size = 346460, upload-time = "2025-06-10T00:45:12.055Z" }, + { url = "https://files.pythonhosted.org/packages/70/fd/af94f04f275f95da2c3b8b5e1d49e3e79f1ed8b6ceb0f1664cbd902773ff/yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513", size = 334486, upload-time = "2025-06-10T00:45:13.995Z" }, + { url = "https://files.pythonhosted.org/packages/84/65/04c62e82704e7dd0a9b3f61dbaa8447f8507655fd16c51da0637b39b2910/yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f", size = 342219, upload-time = "2025-06-10T00:45:16.479Z" }, + { url = "https://files.pythonhosted.org/packages/91/95/459ca62eb958381b342d94ab9a4b6aec1ddec1f7057c487e926f03c06d30/yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390", size = 350693, upload-time = "2025-06-10T00:45:18.399Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/d393e82dd955ad20617abc546a8f1aee40534d599ff555ea053d0ec9bf03/yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458", size = 355803, upload-time = "2025-06-10T00:45:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ed/c5fb04869b99b717985e244fd93029c7a8e8febdfcffa06093e32d7d44e7/yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e", size = 341709, upload-time = "2025-06-10T00:45:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/24/fd/725b8e73ac2a50e78a4534ac43c6addf5c1c2d65380dd48a9169cc6739a9/yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d", size = 86591, upload-time = "2025-06-10T00:45:25.793Z" }, + { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, ] diff --git a/libraries/python/openai-client/.vscode/settings.json b/libraries/python/openai-client/.vscode/settings.json index 197b82a0f..9adaf355b 100644 --- a/libraries/python/openai-client/.vscode/settings.json +++ b/libraries/python/openai-client/.vscode/settings.json @@ -36,7 +36,7 @@ "**/.data": true, "**/__pycache__": true }, - // For use with optional extension: "streetsidesoftware.code-spell-checker" + // For use with optional extension: "streetsidesoftware.code-spell-checker" "cSpell.ignorePaths": [ ".venv", "node_modules", @@ -48,6 +48,7 @@ "addopts", "asctime", "contentsafety", + "elts", "levelname", "openai", "Pydantic", @@ -56,4 +57,4 @@ "testpaths", "tiktoken" ] -} +} \ No newline at end of file diff --git a/libraries/python/openai-client/openai_client/__init__.py b/libraries/python/openai-client/openai_client/__init__.py index 56a64cb12..10448442f 100644 --- a/libraries/python/openai-client/openai_client/__init__.py +++ b/libraries/python/openai-client/openai_client/__init__.py @@ -3,7 +3,11 @@ from .client import ( create_client, ) -from .completion import completion_structured, message_content_from_completion, message_from_completion +from .completion import ( + completion_structured, + message_content_from_completion, + message_from_completion, +) from .config import ( AzureOpenAIApiKeyAuthConfig, AzureOpenAIAzureIdentityAuthConfig, @@ -22,6 +26,7 @@ add_serializable_data, extra_data, make_completion_args_serializable, + serializable, ) from .messages import ( convert_from_completion_messages, @@ -74,6 +79,7 @@ "num_tokens_from_tools_and_messages", "OpenAIServiceConfig", "OpenAIRequestConfig", + "serializable", "ServiceConfig", "truncate_messages_for_logging", "validate_completion", diff --git a/libraries/python/openai-client/openai_client/completion.py b/libraries/python/openai-client/openai_client/completion.py index c4f37dc69..9ea8a920f 100644 --- a/libraries/python/openai-client/openai_client/completion.py +++ b/libraries/python/openai-client/openai_client/completion.py @@ -25,7 +25,9 @@ JSON_OBJECT_RESPONSE_FORMAT: ResponseFormat = {"type": "json_object"} -def assistant_message_from_completion(completion: ParsedChatCompletion[None]) -> ChatCompletionAssistantMessageParam: +def assistant_message_from_completion( + completion: ParsedChatCompletion, +) -> ChatCompletionAssistantMessageParam: completion_message: ParsedChatCompletionMessage = completion.choices[0].message assistant_message = ChatCompletionAssistantMessageParam(role="assistant") if completion_message.tool_calls: @@ -36,17 +38,26 @@ def assistant_message_from_completion(completion: ParsedChatCompletion[None]) -> return assistant_message -def message_from_completion(completion: ParsedChatCompletion) -> ParsedChatCompletionMessage | None: - return completion.choices[0].message if completion and completion.choices else None +def message_from_completion( + completion: ParsedChatCompletion, +) -> ParsedChatCompletionMessage | None: + if not completion or not completion.choices: + return None + return completion.choices[0].message def message_content_from_completion(completion: ParsedChatCompletion | None) -> str: - if not completion or not completion.choices or not completion.choices[0].message: + if completion is None or not completion.choices: return "" - return completion.choices[0].message.content or "" + content = message_from_completion(completion) + if content and content.content: + return content.content + return "" -def message_content_dict_from_completion(completion: ParsedChatCompletion) -> dict[str, Any] | None: +def message_content_dict_from_completion( + completion: ParsedChatCompletion, +) -> dict[str, Any] | None: message = message_from_completion(completion) if message: if message.parsed: @@ -109,7 +120,10 @@ async def completion_structured( raise NoParsedMessageError() if response.choices[0].finish_reason != "stop": - logger.warning("Unexpected finish reason, expected stop; reason: %s", response.choices[0].finish_reason) + logger.warning( + "Unexpected finish reason, expected stop; reason: %s", + response.choices[0].finish_reason, + ) metadata = { "request": { diff --git a/libraries/python/openai-client/openai_client/logging.py b/libraries/python/openai-client/openai_client/logging.py index ca271570f..28e61c036 100644 --- a/libraries/python/openai-client/openai_client/logging.py +++ b/libraries/python/openai-client/openai_client/logging.py @@ -10,7 +10,9 @@ from pydantic import BaseModel -def make_completion_args_serializable(completion_args: dict[str, Any]) -> dict[str, Any]: +def make_completion_args_serializable( + completion_args: dict[str, Any], +) -> dict[str, Any]: """ We put the completion args into logs and messages, so it's important that they are serializable. This function returns a copy of the completion args @@ -38,6 +40,9 @@ def convert_to_serializable(data: Any) -> Any: """ if isinstance(data, BaseModel): return data.model_dump() + elif inspect.isclass(data) and issubclass(data, BaseModel): + # Handle Pydantic model classes (not instances) + return data.__name__ elif isinstance(data, dict): return {key: convert_to_serializable(value) for key, value in data.items()} elif isinstance(data, list): @@ -58,26 +63,29 @@ def default(self, o): return super().default(o) -def add_serializable_data(data: Any) -> dict[str, Any]: +def serializable(data: Any) -> dict[str, Any]: """ - Helper function to use when adding extra data to log messages. Data will - attempt to be put into a serializable format. + Convert data to a serializable format for logging or other purposes. """ - extra = {} - - # Convert to serializable. data = convert_to_serializable(data) - - # Ensure data is a JSON-serializable object. try: data = json.loads(json.dumps(data, cls=CustomEncoder)) except Exception as e: data = str(e) + return data + +def add_serializable_data(data: Any) -> dict[str, Any]: + """ + Helper function to use when adding extra data to log messages. Data will + attempt to be put into a serializable format. + """ + extra = {} + data = serializable(data) if data: extra["data"] = data - return extra +# Helpful alias extra_data = add_serializable_data diff --git a/libraries/python/openai-client/openai_client/tools.py b/libraries/python/openai-client/openai_client/tools.py index 6c1229427..e4a6b623d 100644 --- a/libraries/python/openai-client/openai_client/tools.py +++ b/libraries/python/openai-client/openai_client/tools.py @@ -1,8 +1,9 @@ import ast import inspect import json +from collections.abc import Callable, Iterable from dataclasses import dataclass -from typing import Any, Callable, Iterable +from typing import Any from openai import ( NOT_GIVEN, @@ -15,13 +16,18 @@ ParsedChatCompletion, ParsedFunctionToolCall, ) +from openai.types.shared_params.function_definition import FunctionDefinition from pydantic import BaseModel, create_model from pydantic.fields import FieldInfo from . import logger from .completion import assistant_message_from_completion from .errors import CompletionError, validate_completion -from .logging import add_serializable_data, make_completion_args_serializable +from .logging import ( + add_serializable_data, + make_completion_args_serializable, + serializable, +) def to_string(value: Any) -> str: @@ -33,7 +39,7 @@ def to_string(value: Any) -> str: return "Function executed successfully." elif isinstance(value, str): return value - elif isinstance(value, (int, float)): + elif isinstance(value, int | float): return str(value) elif isinstance(value, dict): return json.dumps(value) @@ -47,7 +53,9 @@ def to_string(value: Any) -> str: return str(value) -def function_list_to_tool_choice(functions: list[str] | None) -> Iterable[ChatCompletionToolParam] | None: +def function_list_to_tool_choice( + functions: list[str] | None, +) -> Iterable[ChatCompletionToolParam] | None: """ Convert a list of function names to a list of ChatCompletionToolParam objects. This is used in the Chat Completions API if you want to tell the @@ -56,10 +64,7 @@ def function_list_to_tool_choice(functions: list[str] | None) -> Iterable[ChatCo if not functions: return None return [ - ChatCompletionToolParam(**{ - "type": "function", - "function": {"name": name}, - }) + ChatCompletionToolParam(type="function", function={"name": name}) for name in functions ] or None @@ -85,15 +90,21 @@ class ToolFunction: generate a usage string (for help messages) """ - def __init__(self, fn: Callable, name: str | None = None, description: str | None = None) -> None: + def __init__( + self, fn: Callable, name: str | None = None, description: str | None = None + ) -> None: self.fn = fn self.name = name or fn.__name__ - self.description = description or inspect.getdoc(fn) or self.name.replace("_", " ").title() + self.description = ( + description or inspect.getdoc(fn) or self.name.replace("_", " ").title() + ) - def parameters(self, exclude: list[str] = []) -> list[Parameter]: + def parameters(self, exclude: list[str] | None = None) -> list[Parameter]: """ This function's parameters and their default values. """ + if exclude is None: + exclude = [] parameters = dict(inspect.signature(self.fn).parameters) for param_name in exclude: del parameters[param_name] @@ -155,7 +166,7 @@ def schema(self, strict: bool = True) -> dict[str, Any]: # Remove title attribute from all properties (not allowed by the Chat # Completions API). properties = parameters_schema["properties"] - for property_key in properties.keys(): + for property_key in properties: if "title" in properties[property_key]: del properties[property_key]["title"] @@ -230,7 +241,9 @@ class ToolFunctions: tool-call completion against the API. """ - def __init__(self, functions: list[ToolFunction] | None = None, with_help: bool = False) -> None: + def __init__( + self, functions: list[ToolFunction] | None = None, with_help: bool = False + ) -> None: # Set up function map. self.function_map: dict[str, ToolFunction] = {} if functions: @@ -251,7 +264,12 @@ def help(self) -> str: usage.sort() return "```text\nCommands:\n" + "\n".join(usage) + "\n```" - def add_function(self, function: Callable, name: str | None = None, description: str | None = None) -> None: + def add_function( + self, + function: Callable, + name: str | None = None, + description: str | None = None, + ) -> None: """Register a function with the tool functions.""" if not name: name = function.__name__ @@ -264,15 +282,21 @@ def get_function(self, name: str) -> ToolFunction | None: return self.function_map.get(name) def get_functions(self) -> list[ToolFunction]: - return [function for function in self.function_map.values()] + return list(self.function_map.values()) async def execute_function( - self, name: str, args: tuple = (), kwargs: dict[str, Any] = {}, string_response: bool = False + self, + name: str, + args: tuple = (), + kwargs: dict[str, Any] | None = None, + string_response: bool = False, ) -> Any: """ Run a function from the ToolFunctions list by name. If string_response is True, the function return value will be converted to a string. """ + if kwargs is None: + kwargs = {} function = self.get_function(name) if not function: raise ValueError(f"Function {name} not found in registry.") @@ -280,20 +304,26 @@ async def execute_function( if string_response: return to_string(response) - async def execute_function_string(self, function_string: str, string_response: bool = False) -> Any: + async def execute_function_string( + self, function_string: str, string_response: bool = False + ) -> Any: """Parse a function string and execute the function.""" try: function, args, kwargs = self.parse_function_string(function_string) except ValueError as e: - raise ValueError(f"{e} Type: `/help` for more information.") + raise ValueError(f"{e} Type: `/help` for more information.") from e if not function: - raise ValueError("Function not found in registry. Type: `/help` for more information.") + raise ValueError( + "Function not found in registry. Type: `/help` for more information." + ) result = await function.execute(*args, **kwargs) if string_response: return to_string(result) @staticmethod - def parse_fn_string(function_string: str) -> tuple[str | None, list[Any], dict[str, Any]]: + def parse_fn_string( + function_string: str, + ) -> tuple[str | None, list[Any], dict[str, Any]]: """ Parse a string representing a function call into its name, positional arguments, and keyword arguments. @@ -309,11 +339,17 @@ def parse_fn_string(function_string: str) -> tuple[str | None, list[Any], dict[s # Parse the string into an AST (Abstract Syntax Tree) try: tree = ast.parse(function_string) - except SyntaxError: - raise ValueError("Invalid function call. Please check your syntax.") + except SyntaxError as err: + raise ValueError( + "Invalid function call. Please check your syntax." + ) from err # Ensure the tree contains exactly one expression (the function call) - if not (isinstance(tree, ast.Module) and len(tree.body) == 1 and isinstance(tree.body[0], ast.Expr)): + if not ( + isinstance(tree, ast.Module) + and len(tree.body) == 1 + and isinstance(tree.body[0], ast.Expr) + ): raise ValueError("Expected a single function call.") # The function call is stored as a `Call` node within the expression @@ -336,9 +372,14 @@ def eval_node(node): elif isinstance(node, ast.Tuple): return tuple(eval_node(elem) for elem in node.elts) elif isinstance(node, ast.Dict): - return {eval_node(key): eval_node(value) for key, value in zip(node.keys, node.values)} + return { + eval_node(key): eval_node(value) + for key, value in zip(node.keys, node.values, strict=False) + } elif isinstance(node, ast.Name): - return node.id # This can return variable names, but we assume they're constants + return ( + node.id + ) # This can return variable names, but we assume they're constants elif isinstance(node, ast.BinOp): # Handling arithmetic expressions return eval(compile(ast.Expression(node), filename="", mode="eval")) elif isinstance(node, ast.Call): @@ -356,7 +397,9 @@ def eval_node(node): return function_name, args, kwargs - def parse_function_string(self, function_string: str) -> tuple[ToolFunction | None, list[Any], dict[str, Any]]: + def parse_function_string( + self, function_string: str + ) -> tuple[ToolFunction | None, list[Any], dict[str, Any]]: """Parse a function call string into a function and its arguments.""" function_name, args, kwargs = ToolFunctions.parse_fn_string(function_string) @@ -377,15 +420,16 @@ def chat_completion_tools(self) -> list[ChatCompletionToolParam] | NotGiven: calls. """ tools = [ - ChatCompletionToolParam(**{ - "type": "function", - "function": func.schema(), - }) + ChatCompletionToolParam( + type="function", function=FunctionDefinition(**func.schema()) + ) for func in self.function_map.values() ] return tools or NOT_GIVEN - async def execute_tool_call(self, tool_call: ParsedFunctionToolCall) -> ChatCompletionMessageParam | None: + async def execute_tool_call( + self, tool_call: ParsedFunctionToolCall + ) -> ChatCompletionMessageParam | None: """ Execute a function as requested by a ParsedFunctionToolCall (generated by the Chat Completions API) and return the response as a @@ -396,24 +440,31 @@ async def execute_tool_call(self, tool_call: ParsedFunctionToolCall) -> ChatComp if self.has_function(function.name): logger.debug( "Function call.", - extra=add_serializable_data({"name": function.name, "arguments": function.arguments}), + extra=add_serializable_data( + {"name": function.name, "arguments": function.arguments} + ), ) value: Any = None try: kwargs: dict[str, Any] = json.loads(function.arguments) - value = await self.execute_function(function.name, (), kwargs, string_response=True) + value = await self.execute_function( + function.name, (), kwargs, string_response=True + ) except Exception as e: logger.error("Error.", extra=add_serializable_data({"error": e})) value = f"Error: {e}" finally: logger.debug( - "Function response.", extra=add_serializable_data({"tool_call_id": tool_call.id, "content": value}) + "Function response.", + extra=add_serializable_data( + {"tool_call_id": tool_call.id, "content": value} + ), ) - return { - "role": "tool", - "content": value, - "tool_call_id": tool_call.id, - } + return { + "role": "tool", + "content": value, + "tool_call_id": tool_call.id, + } else: logger.error(f"Function not found: {function.name}") return None @@ -423,7 +474,7 @@ async def complete_with_tool_calls( async_client: AsyncOpenAI, completion_args: dict[str, Any], tool_functions: ToolFunctions, - metadata: dict[str, Any] = {}, + metadata: dict[str, Any] | None = None, max_tool_call_rounds: int = 5, # Adding a parameter to limit the maximum number of rounds ) -> tuple[ParsedChatCompletion | None, list[ChatCompletionMessageParam]]: """ @@ -441,6 +492,8 @@ async def complete_with_tool_calls( - metadata: Metadata to be added to the completion response. - max_tool_call_rounds: Maximum number of tool call rounds to prevent infinite loops (default: 5) """ + if metadata is None: + metadata = {} messages: list[ChatCompletionMessageParam] = completion_args.get("messages", []) all_new_messages: list[ChatCompletionMessageParam] = [] current_completion = None @@ -455,20 +508,18 @@ async def complete_with_tool_calls( # or we hit the maximum number of rounds while rounds < max_tool_call_rounds: rounds += 1 - - # Prepare arguments for this round - current_args = {**completion_args, "messages": [*messages, *all_new_messages]} - - # Log the completion request round_description = f"round {rounds}" - if rounds == 1: - round_description = "pre-tool" + current_args = {**completion_args, "messages": [*messages, *all_new_messages]} logger.debug( f"Completion call ({round_description}).", - extra=add_serializable_data(make_completion_args_serializable(current_args)), + extra=add_serializable_data( + make_completion_args_serializable(current_args) + ), + ) + metadata[f"completion_request ({round_description})"] = serializable( + current_args ) - metadata[f"completion_request ({round_description})"] = make_completion_args_serializable(current_args) # Make the completion call try: @@ -478,15 +529,23 @@ async def complete_with_tool_calls( validate_completion(current_completion) logger.debug( f"Completion response ({round_description}).", - extra=add_serializable_data({"completion": current_completion.model_dump()}), + extra=add_serializable_data( + {"completion": current_completion.model_dump()} + ), + ) + metadata[f"completion_response ({round_description})"] = ( + current_completion.model_dump() ) - metadata[f"completion_response ({round_description})"] = current_completion.model_dump() except Exception as e: completion_error = CompletionError(e) - metadata[f"completion_error ({round_description})"] = completion_error.message + metadata[f"completion_error ({round_description})"] = ( + completion_error.message + ) logger.error( completion_error.message, - extra=add_serializable_data({"completion_error": completion_error.body, "metadata": metadata}), + extra=add_serializable_data( + {"completion_error": completion_error.body, "metadata": metadata} + ), ) raise completion_error from e @@ -504,7 +563,9 @@ async def complete_with_tool_calls( # Call all tool functions and generate return messages round_tool_messages: list[ChatCompletionMessageParam] = [] for tool_call in completion_message.tool_calls: - function_call_result_message = await tool_functions.execute_tool_call(tool_call) + function_call_result_message = await tool_functions.execute_tool_call( + tool_call + ) if function_call_result_message: round_tool_messages.append(function_call_result_message) all_new_messages.append(function_call_result_message) diff --git a/libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/assistant_app/context.py b/libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/assistant_app/context.py index 78a6952cf..3f18a58f7 100644 --- a/libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/assistant_app/context.py +++ b/libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/assistant_app/context.py @@ -3,9 +3,10 @@ import logging import pathlib import uuid +from collections.abc import AsyncGenerator, AsyncIterator from contextlib import asynccontextmanager from dataclasses import dataclass, field -from typing import Any, AsyncGenerator, AsyncIterator +from typing import Any import httpx import semantic_workbench_api_model @@ -55,6 +56,18 @@ def for_conversation( httpx_client=self._httpx_client, ) + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "title": self.title, + "assistant": { + "id": self.assistant.id, + "name": self.assistant.name, + "_assistant_service_id": self.assistant._assistant_service_id, + "_template_id": self.assistant._template_id, + }, + } + @property def _conversation_client( self, diff --git a/ruff.toml b/ruff.toml index a44141547..bc486d94c 100644 --- a/ruff.toml +++ b/ruff.toml @@ -5,3 +5,21 @@ target-version = "py311" docstring-code-format = true line-ending = "lf" preview = true + +[lint] +select = [ + "F", # pyflakes + "E", # pycodestyle + "I", # isort + "N", # pep8-naming + "UP", # pyupgrade + "RUF", # ruff + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "ISC", # flake8-implicit-str-concat + "PIE", # flake8-pie + "PT", # flake-pytest-style + "PTH", # flake8-use-pathlib + "SIM", # flake8-simplify + "TID", # flake8-tidy-imports +]