diff --git a/client/src/api/adapters/mock/index.test.ts b/client/src/api/adapters/mock/index.test.ts index 3c94e7a..b856ed4 100644 --- a/client/src/api/adapters/mock/index.test.ts +++ b/client/src/api/adapters/mock/index.test.ts @@ -1,6 +1,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { NoteFlowAPI } from '@/api/interface'; -import type { FinalSegment } from '@/api/types'; +import type { + FinalSegment, + UpdateWorkspaceSettingsRequest, + CreateProjectRequest, + AddProjectMemberRequest, + UpdateProjectMemberRoleRequest, + RemoveProjectMemberRequest, + CreateSummarizationTemplateRequest, + UpdateSummarizationTemplateRequest, + ArchiveSummarizationTemplateRequest, + RestoreSummarizationTemplateVersionRequest, + RegisterOidcProviderRequest, + UpdateOidcProviderRequest, + GetOAuthClientConfigRequest, + SetOAuthClientConfigRequest, + UpdateTaskRequest, + UpdateASRConfigurationRequest, + UpdateStreamingConfigurationRequest, + SetHuggingFaceTokenRequest, +} from '@/api/types'; async function loadMockAPI(): Promise { vi.resetModules(); @@ -12,6 +31,11 @@ async function flushTimers() { await vi.runAllTimersAsync(); } +async function run(promise: Promise): Promise { + await flushTimers(); + return promise; +} + describe('mockAPI', () => { beforeEach(() => { vi.useFakeTimers(); @@ -528,4 +552,387 @@ describe('mockAPI', () => { await flushTimers(); await metricsDefaultPromise; }); + + it('merges workspace settings and manages active projects', async () => { + const mockAPI = await loadMockAPI(); + + const workspaceList = await run(mockAPI.listWorkspaces()); + const primaryWorkspace = workspaceList.workspaces[0]; + const secondaryWorkspace = workspaceList.workspaces[1]; + if (!primaryWorkspace || !secondaryWorkspace) { + throw new Error('Expected default workspaces'); + } + + const settings = await run( + mockAPI.getWorkspaceSettings({ workspace_id: primaryWorkspace.id }) + ); + expect(settings.export_rules).toBeUndefined(); + + const updateRequest: UpdateWorkspaceSettingsRequest = { + workspace_id: primaryWorkspace.id, + settings: { + export_rules: { default_format: 'markdown', include_audio: true }, + trigger_rules: { auto_start_enabled: true }, + rag_enabled: true, + }, + }; + const updated = await run(mockAPI.updateWorkspaceSettings(updateRequest)); + expect(updated.export_rules?.default_format).toBe('markdown'); + expect(updated.trigger_rules?.auto_start_enabled).toBe(true); + expect(updated.rag_enabled).toBe(true); + + const mergeRequest: UpdateWorkspaceSettingsRequest = { + workspace_id: primaryWorkspace.id, + settings: { + export_rules: { include_timestamps: true }, + default_summarization_template: 'template-1', + }, + }; + const merged = await run(mockAPI.updateWorkspaceSettings(mergeRequest)); + expect(merged.export_rules?.default_format).toBe('markdown'); + expect(merged.export_rules?.include_timestamps).toBe(true); + expect(merged.default_summarization_template).toBe('template-1'); + + const createProjectRequest: CreateProjectRequest = { + workspace_id: primaryWorkspace.id, + name: ' New Project !! ', + description: 'Testing', + }; + const project = await run(mockAPI.createProject(createProjectRequest)); + expect(project.slug).toBe('new-project'); + + await run( + mockAPI.setActiveProject({ workspace_id: primaryWorkspace.id, project_id: project.id }) + ); + const active = await run(mockAPI.getActiveProject({ workspace_id: primaryWorkspace.id })); + expect(active.project_id).toBe(project.id); + + const missingActive = mockAPI.setActiveProject({ + workspace_id: primaryWorkspace.id, + project_id: 'missing-project', + }); + await flushTimers(); + await expect(missingActive).rejects.toThrow('Project not found'); + + const wrongWorkspace = mockAPI.setActiveProject({ + workspace_id: secondaryWorkspace.id, + project_id: project.id, + }); + await flushTimers(); + await expect(wrongWorkspace).rejects.toThrow('Project does not belong to workspace'); + + const projects = await run( + mockAPI.listProjects({ workspace_id: primaryWorkspace.id, include_archived: true }) + ); + const defaultProject = projects.projects.find((item) => item.is_default); + if (!defaultProject) { + throw new Error('Expected default project'); + } + const archiveDefault = mockAPI.archiveProject(defaultProject.id); + await flushTimers(); + await expect(archiveDefault).rejects.toThrow('Cannot archive default project'); + }); + + it('manages project members and roles', async () => { + const mockAPI = await loadMockAPI(); + const workspaceList = await run(mockAPI.listWorkspaces()); + const workspace = workspaceList.workspaces[0]; + if (!workspace) { + throw new Error('Expected workspace'); + } + + const project = await run( + mockAPI.createProject({ workspace_id: workspace.id, name: 'Members' }) + ); + + const addRequest: AddProjectMemberRequest = { + project_id: project.id, + user_id: 'user-2', + role: 'editor', + }; + const added = await run(mockAPI.addProjectMember(addRequest)); + expect(added.user_id).toBe('user-2'); + + const membersPage = await run( + mockAPI.listProjectMembers({ project_id: project.id, limit: 1, offset: 0 }) + ); + expect(membersPage.total_count).toBeGreaterThan(0); + + const updateRoleRequest: UpdateProjectMemberRoleRequest = { + project_id: project.id, + user_id: 'user-2', + role: 'admin', + }; + const updated = await run(mockAPI.updateProjectMemberRole(updateRoleRequest)); + expect(updated.role).toBe('admin'); + + const missingUpdate = mockAPI.updateProjectMemberRole({ + project_id: project.id, + user_id: 'missing', + role: 'viewer', + }); + await flushTimers(); + await expect(missingUpdate).rejects.toThrow('Membership not found'); + + const removeRequest: RemoveProjectMemberRequest = { + project_id: project.id, + user_id: 'user-2', + }; + const removed = await run(mockAPI.removeProjectMember(removeRequest)); + expect(removed.success).toBe(true); + + const removedAgain = await run(mockAPI.removeProjectMember(removeRequest)); + expect(removedAgain.success).toBe(false); + }); + + it('manages summarization templates and versions', async () => { + const mockAPI = await loadMockAPI(); + const workspaceList = await run(mockAPI.listWorkspaces()); + const workspace = workspaceList.workspaces[0]; + if (!workspace) { + throw new Error('Expected workspace'); + } + + const templates = await run( + mockAPI.listSummarizationTemplates({ workspace_id: workspace.id, include_system: true }) + ); + const systemTemplate = templates.templates.find((item) => item.is_system); + if (!systemTemplate) { + throw new Error('Expected system template'); + } + + const createRequest: CreateSummarizationTemplateRequest = { + workspace_id: workspace.id, + name: ' Custom Template ', + description: ' Description ', + content: 'Hello {{meeting.title}}', + change_note: ' Initial ', + }; + const created = await run(mockAPI.createSummarizationTemplate(createRequest)); + expect(created.template.name).toBe('Custom Template'); + expect(created.version.version_number).toBe(1); + + const fullTemplate = await run( + mockAPI.getSummarizationTemplate({ + template_id: created.template.id, + include_current_version: true, + }) + ); + expect(fullTemplate.current_version?.id).toBe(created.version.id); + + const withoutVersion = await run( + mockAPI.getSummarizationTemplate({ + template_id: created.template.id, + include_current_version: false, + }) + ); + expect(withoutVersion.current_version).toBeUndefined(); + + const updateRequest: UpdateSummarizationTemplateRequest = { + template_id: created.template.id, + name: ' Updated ', + content: 'Updated content', + change_note: ' Revised ', + }; + const updated = await run(mockAPI.updateSummarizationTemplate(updateRequest)); + expect(updated.template.name).toBe('Updated'); + expect(updated.version?.version_number).toBe(2); + + const versions = await run( + mockAPI.listSummarizationTemplateVersions({ template_id: created.template.id }) + ); + expect(versions.total_count).toBe(2); + + const restoreRequest: RestoreSummarizationTemplateVersionRequest = { + template_id: created.template.id, + version_id: created.version.id, + }; + const restored = await run(mockAPI.restoreSummarizationTemplateVersion(restoreRequest)); + expect(restored.current_version_id).toBe(created.version.id); + + const archiveRequest: ArchiveSummarizationTemplateRequest = { + template_id: created.template.id, + }; + const archived = await run(mockAPI.archiveSummarizationTemplate(archiveRequest)); + expect(archived.is_archived).toBe(true); + + const activeList = await run( + mockAPI.listSummarizationTemplates({ workspace_id: workspace.id, include_archived: false }) + ); + expect(activeList.templates.some((item) => item.id === created.template.id)).toBe(false); + + const archivedList = await run( + mockAPI.listSummarizationTemplates({ workspace_id: workspace.id, include_archived: true }) + ); + expect(archivedList.templates.some((item) => item.id === created.template.id)).toBe(true); + + const updateSystem = mockAPI.updateSummarizationTemplate({ + template_id: systemTemplate.id, + name: 'Nope', + }); + await flushTimers(); + await expect(updateSystem).rejects.toThrow('System templates are read-only'); + + const archiveSystem = mockAPI.archiveSummarizationTemplate({ + template_id: systemTemplate.id, + }); + await flushTimers(); + await expect(archiveSystem).rejects.toThrow('System templates are read-only'); + + const systemVersions = await run( + mockAPI.listSummarizationTemplateVersions({ template_id: systemTemplate.id }) + ); + const restoreSystem = mockAPI.restoreSummarizationTemplateVersion({ + template_id: systemTemplate.id, + version_id: systemVersions.versions[0]?.id ?? 'missing', + }); + await flushTimers(); + await expect(restoreSystem).rejects.toThrow('System templates are read-only'); + }); + + it('handles auth, calendar, oidc, and config flows', async () => { + const mockAPI = await loadMockAPI(); + + const auth = await run(mockAPI.initiateAuthLogin('google')); + expect(auth.auth_url).toContain('http'); + + const completed = await run(mockAPI.completeAuthLogin('google', 'code', 'state')); + expect(completed.display_name).toBe('Google User'); + + const logout = await run(mockAPI.logout()); + expect(logout.tokens_revoked).toBe(true); + + const providers = await run(mockAPI.getCalendarProviders()); + expect(providers.providers.length).toBeGreaterThan(0); + + const calendarAuth = await run(mockAPI.initiateCalendarAuth('google')); + expect(calendarAuth.auth_url).toContain('http'); + + const calendarComplete = await run(mockAPI.completeCalendarAuth('google', 'code', 'state')); + expect(calendarComplete.success).toBe(true); + + const oauthStatus = await run(mockAPI.getOAuthConnectionStatus('google')); + expect(oauthStatus.connection.provider).toBe('google'); + + const oauthConfigRequest: GetOAuthClientConfigRequest = { provider: 'google' }; + const oauthConfig = await run(mockAPI.getOAuthClientConfig(oauthConfigRequest)); + expect(oauthConfig.config.override_enabled).toBe(false); + + const setConfigRequest: SetOAuthClientConfigRequest = { + provider: 'google', + config: { + client_id: 'id', + redirect_uri: 'http://localhost', + scopes: ['openid'], + override_enabled: true, + }, + }; + const setConfig = await run(mockAPI.setOAuthClientConfig(setConfigRequest)); + expect(setConfig.success).toBe(true); + + const diagnostics = await run(mockAPI.runConnectionDiagnostics()); + expect(diagnostics.clientConnected).toBe(false); + + const listEvents = await run(mockAPI.listCalendarEvents()); + expect(listEvents.events).toHaveLength(0); + + const oidcRegister: RegisterOidcProviderRequest = { + workspace_id: 'workspace-1', + name: 'Provider', + issuer_url: 'https://issuer', + client_id: 'client', + preset: 'custom', + scopes: [], + allowed_groups: [], + auto_discover: true, + }; + const oidcProvider = await run(mockAPI.registerOidcProvider(oidcRegister)); + expect(oidcProvider.discovery).toBeDefined(); + + const listProviders = await run(mockAPI.listOidcProviders(undefined, true)); + expect(listProviders.total_count).toBeGreaterThan(0); + + const getProvider = await run(mockAPI.getOidcProvider(oidcProvider.id)); + expect(getProvider.id).toBe(oidcProvider.id); + + const updateRequest: UpdateOidcProviderRequest = { + provider_id: oidcProvider.id, + scopes: [], + allowed_groups: ['admins'], + enabled: false, + }; + const updated = await run(mockAPI.updateOidcProvider(updateRequest)); + expect(updated.enabled).toBe(false); + + const refreshed = await run(mockAPI.refreshOidcDiscovery(oidcProvider.id)); + expect(refreshed.success_count).toBe(1); + + const refreshMissing = await run(mockAPI.refreshOidcDiscovery('missing')); + expect(refreshMissing.failure_count).toBe(1); + + const presets = await run(mockAPI.listOidcPresets()); + expect(presets.presets.length).toBeGreaterThan(0); + + const deleted = await run(mockAPI.deleteOidcProvider(oidcProvider.id)); + expect(deleted.success).toBe(true); + }); + + it('handles ASR, streaming, token, tasks, and analytics flows', async () => { + const mockAPI = await loadMockAPI(); + + const asrConfig = await run(mockAPI.getAsrConfiguration()); + expect(asrConfig.availableModelSizes.length).toBeGreaterThan(0); + + const updateAsrRequest: UpdateASRConfigurationRequest = { + modelSize: 'base', + device: 'cpu', + computeType: 'int8', + }; + const asrUpdate = await run(mockAPI.updateAsrConfiguration(updateAsrRequest)); + expect(asrUpdate.accepted).toBe(true); + + const asrStatus = await run(mockAPI.getAsrJobStatus(asrUpdate.jobId)); + expect(asrStatus.status).toBe('completed'); + + const streaming = await run(mockAPI.getStreamingConfiguration()); + expect(streaming.maxSegmentDurationSeconds).toBeGreaterThan(0); + + const updateStreamingRequest: UpdateStreamingConfigurationRequest = { + partialCadenceSeconds: 1.5, + }; + const updatedStreaming = await run( + mockAPI.updateStreamingConfiguration(updateStreamingRequest) + ); + expect(updatedStreaming.partialCadenceSeconds).toBe(2.0); + + const tokenRequest: SetHuggingFaceTokenRequest = { token: 'hf_token', validate: true }; + const tokenResult = await run(mockAPI.setHuggingFaceToken(tokenRequest)); + expect(tokenResult.success).toBe(true); + + const tokenStatus = await run(mockAPI.getHuggingFaceTokenStatus()); + expect(tokenStatus.isConfigured).toBe(false); + + const tokenValidation = await run(mockAPI.validateHuggingFaceToken()); + expect(tokenValidation.valid).toBe(false); + + const tasks = await run(mockAPI.listTasks()); + expect(tasks.total_count).toBe(0); + + const updateTaskRequest: UpdateTaskRequest = { + task_id: 'task-1', + text: 'Follow up', + status: 'done', + }; + const updatedTask = await run(mockAPI.updateTask(updateTaskRequest)); + expect(updatedTask.status).toBe('done'); + + const overview = await run(mockAPI.getAnalyticsOverview()); + expect(overview.total_meetings).toBe(0); + + const speakerStats = await run(mockAPI.listSpeakerStats()); + expect(speakerStats.speakers).toHaveLength(0); + + const entityAnalytics = await run(mockAPI.getEntityAnalytics()); + expect(entityAnalytics.total_entities).toBe(0); + }); }); diff --git a/pyproject.toml b/pyproject.toml index b2ddde6..25d37b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,10 @@ dependencies = [ "sounddevice>=0.5.3", "spacy>=3.8.11", "openai-whisper>=20250625", + "langgraph>=1.0.6", + "langgraph-checkpoint-postgres>=3.0.3", + "langgraph-checkpoint-redis>=0.3.2", + "psycopg>=3.3.2", ] [project.optional-dependencies] diff --git a/src/noteflow/application/services/asr_config/persistence.py b/src/noteflow/application/services/asr_config/persistence.py index 4c38d26..f97e4ab 100644 --- a/src/noteflow/application/services/asr_config/persistence.py +++ b/src/noteflow/application/services/asr_config/persistence.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from typing import Final, Protocol, TypedDict, cast @@ -34,7 +35,7 @@ class AsrPreferenceResolution: class _TorchCudaModule(Protocol): - def is_available(self) -> bool: ... + is_available: Callable[[], bool] class _TorchModule(Protocol): diff --git a/src/noteflow/domain/ai/interrupts.py b/src/noteflow/domain/ai/interrupts.py index 0ee6985..bb9dc10 100644 --- a/src/noteflow/domain/ai/interrupts.py +++ b/src/noteflow/domain/ai/interrupts.py @@ -12,6 +12,8 @@ from dataclasses import dataclass, field from enum import StrEnum from typing import Final +from noteflow.domain.constants.fields import ACTION, CONFIG, COUNT + class InterruptType(StrEnum): """Types of human-in-the-loop interrupts.""" @@ -29,9 +31,19 @@ class InterruptAction(StrEnum): MODIFY = "modify" -DEFAULT_WEB_SEARCH_OPTIONS: Final[tuple[str, ...]] = ("approve", "reject") -DEFAULT_ANNOTATION_OPTIONS: Final[tuple[str, ...]] = ("approve", "reject", "modify") -DEFAULT_SENSITIVE_OPTIONS: Final[tuple[str, ...]] = ("approve", "reject") +DEFAULT_WEB_SEARCH_OPTIONS: Final[tuple[str, ...]] = ( + InterruptAction.APPROVE.value, + InterruptAction.REJECT.value, +) +DEFAULT_ANNOTATION_OPTIONS: Final[tuple[str, ...]] = ( + InterruptAction.APPROVE.value, + InterruptAction.REJECT.value, + InterruptAction.MODIFY.value, +) +DEFAULT_SENSITIVE_OPTIONS: Final[tuple[str, ...]] = ( + InterruptAction.APPROVE.value, + InterruptAction.REJECT.value, +) @dataclass(frozen=True) @@ -61,18 +73,18 @@ class InterruptRequest: interrupt_type: InterruptType message: str context: dict[str, object] = field(default_factory=dict) - options: tuple[str, ...] = field(default_factory=lambda: ("approve", "reject")) + options: tuple[str, ...] = field(default_factory=lambda: DEFAULT_WEB_SEARCH_OPTIONS) config: InterruptConfig = field(default_factory=InterruptConfig) request_id: str = "" - def to_dict(self) -> dict[str, object]: + def to_request_payload(self) -> dict[str, object]: """Convert to dictionary for serialization.""" return { "interrupt_type": self.interrupt_type, "message": self.message, "context": self.context, "options": list(self.options), - "config": { + CONFIG: { "allow_ignore": self.config.allow_ignore, "allow_modify": self.config.allow_modify, "timeout_seconds": self.config.timeout_seconds, @@ -114,10 +126,10 @@ class InterruptResponse: """Check if the action was modified.""" return self.action == InterruptAction.MODIFY - def to_dict(self) -> dict[str, object]: + def to_response_payload(self) -> dict[str, object]: """Convert to dictionary for serialization.""" result: dict[str, object] = { - "action": self.action, + ACTION: self.action, "request_id": self.request_id, } if self.modified_value is not None: @@ -143,11 +155,13 @@ def create_web_search_interrupt( Returns: InterruptRequest configured for web search approval. """ + options = DEFAULT_ANNOTATION_OPTIONS if allow_modify else DEFAULT_WEB_SEARCH_OPTIONS + message = _build_web_search_message(query) return InterruptRequest( interrupt_type=InterruptType.WEB_SEARCH_APPROVAL, - message=f"Allow web search for additional context? Query: {query[:100]}", + message=message, context={"query": query}, - options=("approve", "reject", "modify") if allow_modify else DEFAULT_WEB_SEARCH_OPTIONS, + options=options, config=InterruptConfig(allow_modify=allow_modify), request_id=request_id, ) @@ -170,7 +184,7 @@ def create_annotation_interrupt( return InterruptRequest( interrupt_type=InterruptType.ANNOTATION_APPROVAL, message=f"Apply {count} suggested annotation(s)?", - context={"annotations": annotations, "count": count}, + context={"annotations": annotations, COUNT: count}, options=DEFAULT_ANNOTATION_OPTIONS, config=InterruptConfig(allow_modify=True, allow_ignore=True), request_id=request_id, @@ -192,11 +206,20 @@ def create_sensitive_action_interrupt( Returns: InterruptRequest configured for sensitive action confirmation. """ + message = _build_sensitive_action_message(action_name) return InterruptRequest( interrupt_type=InterruptType.SENSITIVE_ACTION, - message=f"Confirm action: {action_name}", + message=message, context={"action_name": action_name, "description": action_description}, options=DEFAULT_SENSITIVE_OPTIONS, config=InterruptConfig(allow_ignore=False), request_id=request_id, ) + + +def _build_web_search_message(query: str) -> str: + return f"Allow web search for additional context? Query: {query[:100]}" + + +def _build_sensitive_action_message(action_name: str) -> str: + return f"Confirm action: {action_name}" diff --git a/src/noteflow/domain/ai/ports.py b/src/noteflow/domain/ai/ports.py index c49749f..25280aa 100644 --- a/src/noteflow/domain/ai/ports.py +++ b/src/noteflow/domain/ai/ports.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass from typing import TYPE_CHECKING, Protocol if TYPE_CHECKING: @@ -13,14 +14,16 @@ if TYPE_CHECKING: class AssistantPort(Protocol): """Protocol for AI assistant operations.""" - async def ask( - self, - question: str, - user_id: UUID, - meeting_id: UUID | None = None, - thread_id: str | None = None, - allow_web: bool = False, - top_k: int = 8, - ) -> AssistantOutputState: + async def ask(self, request: AssistantRequest) -> AssistantOutputState: """Ask a question about meeting transcript(s).""" ... + + +@dataclass(frozen=True) +class AssistantRequest: + question: str + user_id: UUID + meeting_id: UUID | None = None + thread_id: str | None = None + allow_web: bool = False + top_k: int = 8 diff --git a/src/noteflow/domain/constants/fields.py b/src/noteflow/domain/constants/fields.py index 7f99a49..299be11 100644 --- a/src/noteflow/domain/constants/fields.py +++ b/src/noteflow/domain/constants/fields.py @@ -24,6 +24,9 @@ UPDATED_AT: Final[Literal["updated_at"]] = "updated_at" PROJECT_ID: Final[str] = "project_id" PROJECT_IDS: Final[str] = "project_ids" OPTIONS: Final[str] = "options" +ACTION: Final[Literal["action"]] = "action" +COUNT: Final[Literal["count"]] = "count" +CONFIG: Final[Literal["config"]] = "config" CALENDAR: Final[str] = "calendar" CLAIM_MAPPING: Final[str] = "claim_mapping" REQUIRE_EMAIL_VERIFIED: Final[str] = "require_email_verified" diff --git a/src/noteflow/grpc/mixins/calendar_oauth_config.py b/src/noteflow/grpc/mixins/calendar_oauth_config.py index bfdce1e..ae14237 100644 --- a/src/noteflow/grpc/mixins/calendar_oauth_config.py +++ b/src/noteflow/grpc/mixins/calendar_oauth_config.py @@ -7,7 +7,7 @@ from uuid import UUID from noteflow.application.services.calendar import CalendarServiceError from noteflow.config.constants.errors import ERROR_WORKSPACE_ADMIN_REQUIRED -from noteflow.domain.constants.fields import ENTITY_WORKSPACE +from noteflow.domain.constants.fields import CONFIG, ENTITY_WORKSPACE from noteflow.domain.value_objects import OAuthClientConfig from ..proto import noteflow_pb2 @@ -115,7 +115,7 @@ class CalendarOAuthConfigMixin: if not request.provider: await abort_invalid_argument(context, "Provider is required") raise AssertionError(UNREACHABLE_ERROR) from None - if not request.HasField("config"): + if not request.HasField(CONFIG): await abort_invalid_argument(context, "OAuth config is required") raise AssertionError(UNREACHABLE_ERROR) from None diff --git a/src/noteflow/infrastructure/ai/_langgraph_compat.py b/src/noteflow/infrastructure/ai/_langgraph_compat.py new file mode 100644 index 0000000..eb21f5e --- /dev/null +++ b/src/noteflow/infrastructure/ai/_langgraph_compat.py @@ -0,0 +1,56 @@ +"""LangGraph compatibility layer for basedpyright strict mode. + +This module provides typed wrappers for LangGraph types that have incomplete +or incorrect type annotations. All pyright: ignore comments are isolated here +to keep the rest of the codebase clean. + +The LangGraph Command class is a frozen dataclass with Generic[N] inheritance +that basedpyright misinterprets as requiring a 'value' parameter. This module +wraps Command construction to provide proper typing. + +Issue: basedpyright reports "Argument missing for parameter 'value'" on +Command() calls even though Command has no 'value' parameter. This appears +to be a basedpyright issue with Generic dataclasses. + +Usage: + from noteflow.infrastructure.ai._langgraph_compat import create_command +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +from langgraph.types import Command + +if TYPE_CHECKING: + pass + + +def create_command( + *, + graph: str | None = None, + update: Any | None = None, + resume: dict[str, Any] | Any | None = None, +) -> Command[Any]: + """Create a LangGraph Command with proper typing. + + This wrapper isolates the pyright: ignore needed for Command construction + due to basedpyright's incorrect handling of Generic dataclasses. + + Args: + graph: Graph to send the command to (None = current graph). + update: Update to apply to the graph's state. + resume: Value to resume execution with (for interrupt handling). + + Returns: + A properly typed Command instance. + """ + command = cast( + Command[Any], + Command( # pyright: ignore[reportCallIssue] + graph=graph, + update=update, + resume=resume, + ), + ) + return command diff --git a/src/noteflow/infrastructure/ai/cache.py b/src/noteflow/infrastructure/ai/cache.py index 6476262..4693522 100644 --- a/src/noteflow/infrastructure/ai/cache.py +++ b/src/noteflow/infrastructure/ai/cache.py @@ -28,7 +28,7 @@ class CacheEntry: embedding: tuple[float, ...] created_at: float - def is_expired(self, ttl_seconds: float, current_time: float) -> bool: + def is_cache_expired(self, ttl_seconds: float, current_time: float) -> bool: """Check if entry has expired based on TTL.""" return (current_time - self.created_at) > ttl_seconds @@ -72,62 +72,23 @@ class EmbeddingCache: key = self._compute_key(text) current_time = time.monotonic() - existing_future: asyncio.Future[list[float]] | None = None + cached = await self._get_cached_embedding(key, current_time, count_hit=True) + if cached is not None: + return cached - async with self._lock: - if key in self._cache: - entry = self._cache[key] - if not entry.is_expired(self.ttl_seconds, current_time): - self._cache.move_to_end(key) - self._stats.hits += 1 - logger.debug("cache_hit", key=key[:16]) - return list(entry.embedding) - del self._cache[key] - self._stats.expirations += 1 - logger.debug("cache_expired", key=key[:16]) - - if key in self._in_flight: - logger.debug("cache_in_flight_join", key=key[:16]) - existing_future = self._in_flight[key] - - if existing_future is not None: - return list(await existing_future) - - new_future: asyncio.Future[list[float]] = asyncio.get_running_loop().create_future() - - async with self._lock: - if key in self._in_flight: - existing_future = self._in_flight[key] - else: - self._stats.misses += 1 - self._in_flight[key] = new_future - - if existing_future is not None: - return list(await existing_future) + future, is_new = await self._get_or_register_in_flight(key) + if not is_new: + return list(await future) try: embedding = await embedder.embed(text) except Exception: - async with self._lock: - _ = self._in_flight.pop(key, None) - new_future.set_exception(asyncio.CancelledError()) + await self._clear_in_flight(key) + future.set_exception(asyncio.CancelledError()) raise - async with self._lock: - _ = self._in_flight.pop(key, None) - - while len(self._cache) >= self.max_size: - evicted_key, _ = self._cache.popitem(last=False) - self._stats.evictions += 1 - logger.debug("cache_eviction", evicted_key=evicted_key[:16]) - - self._cache[key] = CacheEntry( - embedding=tuple(embedding), - created_at=current_time, - ) - logger.debug("cache_store", key=key[:16]) - - new_future.set_result(embedding) + await self._store_embedding(key, embedding, current_time) + future.set_result(embedding) return embedding async def get(self, text: str) -> list[float] | None: @@ -141,18 +102,7 @@ class EmbeddingCache: """ key = self._compute_key(text) current_time = time.monotonic() - - async with self._lock: - if key in self._cache: - entry = self._cache[key] - if not entry.is_expired(self.ttl_seconds, current_time): - self._cache.move_to_end(key) - return list(entry.embedding) - else: - del self._cache[key] - self._stats.expirations += 1 - - return None + return await self._get_cached_embedding(key, current_time, count_hit=False) async def clear(self) -> int: """Clear all cached entries. @@ -171,15 +121,75 @@ class EmbeddingCache: async with self._lock: return len(self._cache) - def get_stats(self) -> EmbeddingCacheStats: + def stats_snapshot(self) -> EmbeddingCacheStats: """Get cache statistics (not async - reads are atomic).""" + stats = self._stats return EmbeddingCacheStats( - hits=self._stats.hits, - misses=self._stats.misses, - evictions=self._stats.evictions, - expirations=self._stats.expirations, + hits=stats.hits, + misses=stats.misses, + evictions=stats.evictions, + expirations=stats.expirations, ) + async def _get_cached_embedding( + self, + key: str, + current_time: float, + *, + count_hit: bool, + ) -> list[float] | None: + async with self._lock: + entry = self._cache.get(key) + if entry is None: + return None + if entry.is_cache_expired(self.ttl_seconds, current_time): + del self._cache[key] + self._stats.expirations += 1 + logger.debug("cache_expired", key=key[:16]) + return None + self._cache.move_to_end(key) + if count_hit: + self._stats.hits += 1 + logger.debug("cache_hit", key=key[:16]) + return list(entry.embedding) + + async def _get_or_register_in_flight( + self, + key: str, + ) -> tuple[asyncio.Future[list[float]], bool]: + async with self._lock: + if key in self._in_flight: + logger.debug("cache_in_flight_join", key=key[:16]) + return self._in_flight[key], False + future: asyncio.Future[list[float]] = asyncio.get_running_loop().create_future() + self._stats.misses += 1 + self._in_flight[key] = future + return future, True + + async def _clear_in_flight(self, key: str) -> None: + async with self._lock: + _ = self._in_flight.pop(key, None) + + async def _store_embedding( + self, + key: str, + embedding: list[float], + current_time: float, + ) -> None: + async with self._lock: + _ = self._in_flight.pop(key, None) + + while len(self._cache) >= self.max_size: + evicted_key, _ = self._cache.popitem(last=False) + self._stats.evictions += 1 + logger.debug("cache_eviction", evicted_key=evicted_key[:16]) + + self._cache[key] = CacheEntry( + embedding=tuple(embedding), + created_at=current_time, + ) + logger.debug("cache_store", key=key[:16]) + class CachedEmbedder: """Wrapper that adds caching to any EmbedderProtocol implementation. diff --git a/src/noteflow/infrastructure/ai/constants.py b/src/noteflow/infrastructure/ai/constants.py new file mode 100644 index 0000000..76b07f6 --- /dev/null +++ b/src/noteflow/infrastructure/ai/constants.py @@ -0,0 +1,23 @@ +"""Shared constants for AI graph state keys and node names.""" + +from __future__ import annotations + +from typing import Final + +# Graph node names +NODE_RETRIEVE: Final[str] = "retrieve" +NODE_VERIFY: Final[str] = "verify" +NODE_SYNTHESIZE: Final[str] = "synthesize" +NODE_WEB_SEARCH_APPROVAL: Final[str] = "web_search_approval" +NODE_WEB_SEARCH: Final[str] = "web_search" +NODE_SUMMARIZE: Final[str] = "summarize" + +# Graph state keys +STATE_QUESTION: Final[str] = "question" +STATE_RETRIEVED_SEGMENTS: Final[str] = "retrieved_segments" +STATE_VERIFICATION_PASSED: Final[str] = "verification_passed" +STATE_WEB_SEARCH_APPROVED: Final[str] = "web_search_approved" +STATE_WEB_CONTEXT: Final[str] = "web_context" +STATE_ANSWER: Final[str] = "answer" +STATE_CITATIONS: Final[str] = "citations" +STATE_SUGGESTED_ANNOTATIONS: Final[str] = "suggested_annotations" diff --git a/src/noteflow/infrastructure/ai/graphs/_shared.py b/src/noteflow/infrastructure/ai/graphs/_shared.py new file mode 100644 index 0000000..db45e8b --- /dev/null +++ b/src/noteflow/infrastructure/ai/graphs/_shared.py @@ -0,0 +1,44 @@ +"""Shared helpers for AI graph wiring.""" + +from __future__ import annotations + +from typing import Protocol + +from noteflow.infrastructure.ai.constants import ( + NODE_RETRIEVE, + NODE_SYNTHESIZE, + NODE_VERIFY, + NODE_WEB_SEARCH, + NODE_WEB_SEARCH_APPROVAL, + STATE_ANSWER, + STATE_CITATIONS, + STATE_SUGGESTED_ANNOTATIONS, +) + + +class GraphBuilder(Protocol): + def add_edge(self, start: object, end: object) -> None: ... + + +def build_no_information_payload(answer: str) -> dict[str, object]: + return { + STATE_ANSWER: answer, + STATE_CITATIONS: [], + STATE_SUGGESTED_ANNOTATIONS: [], + } + + +def connect_web_search_flow(builder: GraphBuilder, start: object, end: object) -> None: + builder.add_edge(start, NODE_RETRIEVE) + builder.add_edge(NODE_RETRIEVE, NODE_VERIFY) + builder.add_edge(NODE_VERIFY, NODE_WEB_SEARCH_APPROVAL) + builder.add_edge(NODE_WEB_SEARCH_APPROVAL, NODE_WEB_SEARCH) + builder.add_edge(NODE_WEB_SEARCH, NODE_SYNTHESIZE) + builder.add_edge(NODE_SYNTHESIZE, end) + + +def connect_base_flow(builder: GraphBuilder, start: object, end: object) -> None: + builder.add_edge(start, NODE_RETRIEVE) + builder.add_edge(NODE_RETRIEVE, NODE_VERIFY) + builder.add_edge(NODE_VERIFY, NODE_SYNTHESIZE) + builder.add_edge(NODE_SYNTHESIZE, end) diff --git a/src/noteflow/infrastructure/ai/graphs/meeting_qa.py b/src/noteflow/infrastructure/ai/graphs/meeting_qa.py index 97c8f54..fb816c7 100644 --- a/src/noteflow/infrastructure/ai/graphs/meeting_qa.py +++ b/src/noteflow/infrastructure/ai/graphs/meeting_qa.py @@ -2,8 +2,9 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import TYPE_CHECKING, Final, TypedDict +from typing import TYPE_CHECKING, Final, TypedDict, cast if TYPE_CHECKING: from langgraph.graph import CompiledStateGraph @@ -12,6 +13,7 @@ if TYPE_CHECKING: from noteflow.domain.value_objects import MeetingId from noteflow.infrastructure.ai.nodes.annotation_suggester import SuggestedAnnotation from noteflow.infrastructure.ai.nodes.web_search import WebSearchProvider + from noteflow.infrastructure.ai.tools.synthesis import SynthesisResult from noteflow.infrastructure.ai.tools.retrieval import ( EmbedderProtocol, RetrievalResult, @@ -19,6 +21,27 @@ if TYPE_CHECKING: ) from noteflow.infrastructure.ai.tools.synthesis import LLMProtocol +from noteflow.infrastructure.ai.constants import ( + NODE_RETRIEVE, + NODE_SYNTHESIZE, + NODE_VERIFY, + NODE_WEB_SEARCH, + NODE_WEB_SEARCH_APPROVAL, + STATE_ANSWER, + STATE_CITATIONS, + STATE_QUESTION, + STATE_RETRIEVED_SEGMENTS, + STATE_SUGGESTED_ANNOTATIONS, + STATE_VERIFICATION_PASSED, + STATE_WEB_CONTEXT, + STATE_WEB_SEARCH_APPROVED, +) +from noteflow.infrastructure.ai.graphs._shared import ( + build_no_information_payload, + connect_base_flow, + connect_web_search_flow, +) + MEETING_QA_GRAPH_NAME: Final[str] = "meeting_qa" MEETING_QA_GRAPH_VERSION: Final[int] = 2 NO_INFORMATION_ANSWER: Final[str] = "I couldn't find relevant information in this meeting." @@ -31,6 +54,14 @@ class MeetingQAConfig: require_annotation_approval: bool = False +@dataclass(frozen=True) +class MeetingQADependencies: + embedder: "EmbedderProtocol" + segment_repo: "SegmentSearchProtocol" + llm: "LLMProtocol" + web_search_provider: "WebSearchProvider | None" = None + + class MeetingQAInputState(TypedDict): question: str meeting_id: MeetingId @@ -52,142 +83,197 @@ class MeetingQAInternalState(MeetingQAInputState, MeetingQAOutputState): def build_meeting_qa_graph( - embedder: EmbedderProtocol, - segment_repo: SegmentSearchProtocol, - llm: LLMProtocol, + deps: MeetingQADependencies, *, - web_search_provider: WebSearchProvider | None = None, config: MeetingQAConfig | None = None, checkpointer: object | None = None, ) -> CompiledStateGraph[MeetingQAInternalState]: - """Build a Q&A graph for single-meeting questions with segment citations. - - Graph flow (with web search): retrieve -> verify -> [web_search_approval] -> [web_search] -> synthesize - Graph flow (without): retrieve -> verify -> synthesize - - Args: - embedder: Protocol for generating text embeddings. - segment_repo: Protocol for semantic segment search. - llm: Protocol for LLM text completion. - web_search_provider: Optional web search provider for augmentation. - config: Graph configuration for features/interrupts. - checkpointer: Optional checkpointer for interrupt support. - - Returns: - Compiled graph that accepts question/meeting_id and returns answer/citations. - """ + """Build a Q&A graph for single-meeting questions with segment citations.""" from langgraph.graph import END, START, StateGraph - from noteflow.domain.ai.citations import SegmentCitation - from noteflow.infrastructure.ai.interrupts import check_web_search_approval - from noteflow.infrastructure.ai.nodes.annotation_suggester import ( - extract_annotations_from_answer, + effective_config = config or MeetingQAConfig() + nodes = _build_meeting_nodes(deps, effective_config) + + builder: StateGraph[MeetingQAInternalState] = StateGraph(MeetingQAInternalState) + builder.add_node(NODE_RETRIEVE, nodes.retrieve) + builder.add_node(NODE_VERIFY, nodes.verify) + builder.add_node(NODE_SYNTHESIZE, nodes.synthesize) + + if nodes.web_search_approval is not None and nodes.web_search is not None: + builder.add_node(NODE_WEB_SEARCH_APPROVAL, nodes.web_search_approval) + builder.add_node(NODE_WEB_SEARCH, nodes.web_search) + connect_web_search_flow(builder, START, END) + else: + connect_base_flow(builder, START, END) + + compile_method = getattr(builder, "compile") + compiled: CompiledStateGraph[MeetingQAInternalState] = compile_method(checkpointer=checkpointer) + return compiled + + +@dataclass(frozen=True) +class _MeetingQANodes: + retrieve: Callable[[MeetingQAInternalState], Awaitable[dict[str, object]]] + verify: Callable[[MeetingQAInternalState], Awaitable[dict[str, object]]] + synthesize: Callable[[MeetingQAInternalState], Awaitable[dict[str, object]]] + web_search_approval: Callable[[MeetingQAInternalState], Awaitable[dict[str, object]]] | None + web_search: Callable[[MeetingQAInternalState], Awaitable[dict[str, object]]] | None + + +def _build_meeting_nodes( + deps: MeetingQADependencies, + config: MeetingQAConfig, +) -> _MeetingQANodes: + allow_web_search = config.enable_web_search and deps.web_search_provider is not None + return _MeetingQANodes( + retrieve=_build_meeting_retrieve_node(deps), + verify=_build_verify_node(), + synthesize=_build_meeting_synthesize_node(deps), + web_search_approval=_build_meeting_web_search_approval_node(deps, config) + if allow_web_search + else None, + web_search=_build_meeting_web_search_node(deps, config) if allow_web_search else None, ) + + +def _build_meeting_retrieve_node( + deps: MeetingQADependencies, +) -> Callable[[MeetingQAInternalState], Awaitable[dict[str, object]]]: + from noteflow.infrastructure.ai.tools.retrieval import ( + MeetingRetrievalDependencies, + MeetingRetrievalRequest, + retrieve_segments, + ) + + async def retrieve_node(state: MeetingQAInternalState) -> dict[str, object]: + question = cast(str, state[STATE_QUESTION]) + request = MeetingRetrievalRequest( + query=question, + meeting_id=state["meeting_id"], + top_k=state["top_k"], + ) + results = await retrieve_segments( + request, + MeetingRetrievalDependencies( + embedder=deps.embedder, + segment_repo=deps.segment_repo, + ), + ) + return {STATE_RETRIEVED_SEGMENTS: results} + + return retrieve_node + + +def _build_verify_node() -> Callable[[MeetingQAInternalState], Awaitable[dict[str, object]]]: + async def verify_node(state: MeetingQAInternalState) -> dict[str, object]: + segments = cast(list["RetrievalResult"], state[STATE_RETRIEVED_SEGMENTS]) + has_segments = len(segments) > 0 + return {STATE_VERIFICATION_PASSED: has_segments} + + return verify_node + + +def _build_meeting_web_search_approval_node( + deps: MeetingQADependencies, + config: MeetingQAConfig, +) -> Callable[[MeetingQAInternalState], Awaitable[dict[str, object]]]: + from noteflow.infrastructure.ai.interrupts import check_web_search_approval + from noteflow.infrastructure.ai.nodes.web_search import derive_search_query + + async def web_search_approval_node(state: MeetingQAInternalState) -> dict[str, object]: + if not config.require_web_approval: + return {STATE_WEB_SEARCH_APPROVED: True} + question = cast(str, state[STATE_QUESTION]) + query = derive_search_query(question) + approved = check_web_search_approval(query, require_approval=True) + return {STATE_WEB_SEARCH_APPROVED: approved} + + return web_search_approval_node + + +def _build_meeting_web_search_node( + deps: MeetingQADependencies, + config: MeetingQAConfig, +) -> Callable[[MeetingQAInternalState], Awaitable[dict[str, object]]]: from noteflow.infrastructure.ai.nodes.web_search import ( WebSearchConfig, derive_search_query, execute_web_search, format_results_for_context, ) - from noteflow.infrastructure.ai.tools.retrieval import retrieve_segments - from noteflow.infrastructure.ai.tools.synthesis import synthesize_answer - - effective_config = config or MeetingQAConfig() - - async def retrieve_node(state: MeetingQAInternalState) -> dict[str, object]: - results = await retrieve_segments( - query=state["question"], - embedder=embedder, - segment_repo=segment_repo, - meeting_id=state["meeting_id"], - top_k=state["top_k"], - ) - return {"retrieved_segments": results} - - async def verify_node(state: MeetingQAInternalState) -> dict[str, object]: - has_segments = len(state["retrieved_segments"]) > 0 - return {"verification_passed": has_segments} - - def web_search_approval_node(state: MeetingQAInternalState) -> dict[str, object]: - if not effective_config.enable_web_search or web_search_provider is None: - return {"web_search_approved": False} - - if not effective_config.require_web_approval: - return {"web_search_approved": True} - - query = derive_search_query(state["question"]) - approved = check_web_search_approval(query, require_approval=True) - return {"web_search_approved": approved} async def web_search_node(state: MeetingQAInternalState) -> dict[str, object]: - if not state.get("web_search_approved", False) or web_search_provider is None: - return {"web_context": ""} - - query = derive_search_query(state["question"]) - search_config = WebSearchConfig(enabled=True, require_approval=False) - response = await execute_web_search(query, web_search_provider, search_config) + approved = bool(state.get(STATE_WEB_SEARCH_APPROVED, False)) + if not approved or deps.web_search_provider is None: + return {STATE_WEB_CONTEXT: ""} + question = cast(str, state[STATE_QUESTION]) + query = derive_search_query(question) + search_config = WebSearchConfig(enabled=config.enable_web_search, require_approval=False) + response = await execute_web_search(query, deps.web_search_provider, search_config) context = format_results_for_context(response.results) - return {"web_context": context} + return {STATE_WEB_CONTEXT: context} + + return web_search_node + + +def _build_meeting_synthesize_node( + deps: MeetingQADependencies, +) -> Callable[[MeetingQAInternalState], Awaitable[dict[str, object]]]: + from noteflow.infrastructure.ai.nodes.annotation_suggester import ( + extract_annotations_from_answer, + ) + from noteflow.infrastructure.ai.tools.synthesis import synthesize_answer async def synthesize_node(state: MeetingQAInternalState) -> dict[str, object]: - if not state["verification_passed"]: - return { - "answer": NO_INFORMATION_ANSWER, - "citations": [], - "suggested_annotations": [], - } - + if not state[STATE_VERIFICATION_PASSED]: + return build_no_information_payload(NO_INFORMATION_ANSWER) + question = cast(str, state[STATE_QUESTION]) + segments = cast(list["RetrievalResult"], state[STATE_RETRIEVED_SEGMENTS]) result = await synthesize_answer( - question=state["question"], - segments=state["retrieved_segments"], - llm=llm, + question=question, + segments=segments, + llm=deps.llm, + ) + return _build_meeting_answer_payload( + meeting_id=state["meeting_id"], + retrieved_segments=segments, + result=result, + extract_annotations=extract_annotations_from_answer, ) - citations = [ - SegmentCitation( - meeting_id=state["meeting_id"], - segment_id=seg.segment_id, - start_time=seg.start_time, - end_time=seg.end_time, - text=seg.text, - score=seg.score, - ) - for seg in state["retrieved_segments"] - if seg.segment_id in result.cited_segment_ids - ] + return synthesize_node - suggested_annotations = extract_annotations_from_answer( - answer=result.answer, - cited_segment_ids=tuple(result.cited_segment_ids), + +def _build_meeting_answer_payload( + *, + meeting_id: "MeetingId", + retrieved_segments: list["RetrievalResult"], + result: "SynthesisResult", + extract_annotations: Callable[[str, tuple[int, ...]], list["SuggestedAnnotation"]], +) -> dict[str, object]: + from noteflow.domain.ai.citations import SegmentCitation + + cited_ids = set(result.cited_segment_ids) + citations = [ + SegmentCitation( + meeting_id=meeting_id, + segment_id=seg.segment_id, + start_time=seg.start_time, + end_time=seg.end_time, + text=seg.text, + score=seg.score, ) + for seg in retrieved_segments + if seg.segment_id in cited_ids + ] - return { - "answer": result.answer, - "citations": citations, - "suggested_annotations": suggested_annotations, - } + suggested_annotations = extract_annotations( + result.answer, + tuple(result.cited_segment_ids), + ) - builder: StateGraph[MeetingQAInternalState] = StateGraph(MeetingQAInternalState) - builder.add_node("retrieve", retrieve_node) - builder.add_node("verify", verify_node) - builder.add_node("synthesize", synthesize_node) - - if effective_config.enable_web_search and web_search_provider is not None: - builder.add_node("web_search_approval", web_search_approval_node) - builder.add_node("web_search", web_search_node) - - builder.add_edge(START, "retrieve") - builder.add_edge("retrieve", "verify") - builder.add_edge("verify", "web_search_approval") - builder.add_edge("web_search_approval", "web_search") - builder.add_edge("web_search", "synthesize") - builder.add_edge("synthesize", END) - else: - builder.add_edge(START, "retrieve") - builder.add_edge("retrieve", "verify") - builder.add_edge("verify", "synthesize") - builder.add_edge("synthesize", END) - - compile_method = getattr(builder, "compile") - compiled: CompiledStateGraph[MeetingQAInternalState] = compile_method(checkpointer=checkpointer) - return compiled + return { + STATE_ANSWER: result.answer, + STATE_CITATIONS: citations, + STATE_SUGGESTED_ANNOTATIONS: suggested_annotations, + } diff --git a/src/noteflow/infrastructure/ai/graphs/summarization.py b/src/noteflow/infrastructure/ai/graphs/summarization.py index 3c1a5d8..806fa9c 100644 --- a/src/noteflow/infrastructure/ai/graphs/summarization.py +++ b/src/noteflow/infrastructure/ai/graphs/summarization.py @@ -14,10 +14,24 @@ if TYPE_CHECKING: from langgraph.graph import CompiledStateGraph - from noteflow.application.services.summarization import SummarizationService + from noteflow.application.services.summarization import ( + SummarizationService, + SummarizationServiceResult, + ) + from noteflow.domain.entities import ActionItem, KeyPoint from noteflow.domain.entities import Segment from noteflow.domain.value_objects import MeetingId +from noteflow.domain.constants.fields import ( + ACTION_ITEMS, + END_TIME, + KEY_POINTS, + SEGMENT_IDS, + START_TIME, + TEXT, +) +from noteflow.infrastructure.ai.constants import NODE_SUMMARIZE + SUMMARIZATION_GRAPH_NAME: Final[str] = "summarization" SUMMARIZATION_GRAPH_VERSION: Final[int] = 1 @@ -68,36 +82,44 @@ def build_summarization_graph( meeting_id=meeting_id, segments=state["segments"], ) - summary = result.summary - return { - "summary_text": summary.executive_summary, - "key_points": [ - { - "text": kp.text, - "segment_ids": kp.segment_ids, - "start_time": kp.start_time, - "end_time": kp.end_time, - } - for kp in summary.key_points - ], - "action_items": [ - { - "text": ai.text, - "segment_ids": ai.segment_ids, - "assignee": ai.assignee, - "start_time": ai.start_time, - "end_time": ai.end_time, - } - for ai in summary.action_items - ], - "provider_used": result.provider_used, - "tokens_used": summary.tokens_used, - "latency_ms": summary.latency_ms, - } + return _summarization_result_to_payload(result) builder = StateGraph(SummarizationState) - builder.add_node("summarize", summarize_node) - builder.add_edge(START, "summarize") - builder.add_edge("summarize", END) + builder.add_node(NODE_SUMMARIZE, summarize_node) + builder.add_edge(START, NODE_SUMMARIZE) + builder.add_edge(NODE_SUMMARIZE, END) return builder.compile() + + +def _summarization_result_to_payload( + result: "SummarizationServiceResult", +) -> dict[str, object]: + summary = result.summary + return { + "summary_text": summary.executive_summary, + KEY_POINTS: [_key_point_to_payload(kp) for kp in summary.key_points], + ACTION_ITEMS: [_action_item_to_payload(ai) for ai in summary.action_items], + "provider_used": result.provider_used, + "tokens_used": summary.tokens_used, + "latency_ms": summary.latency_ms, + } + + +def _key_point_to_payload(key_point: "KeyPoint") -> dict[str, object]: + return { + TEXT: key_point.text, + SEGMENT_IDS: key_point.segment_ids, + START_TIME: key_point.start_time, + END_TIME: key_point.end_time, + } + + +def _action_item_to_payload(action_item: "ActionItem") -> dict[str, object]: + return { + TEXT: action_item.text, + SEGMENT_IDS: action_item.segment_ids, + "assignee": action_item.assignee, + START_TIME: action_item.start_time, + END_TIME: action_item.end_time, + } diff --git a/src/noteflow/infrastructure/ai/graphs/workspace_qa.py b/src/noteflow/infrastructure/ai/graphs/workspace_qa.py index 6a5a9c1..490e38f 100644 --- a/src/noteflow/infrastructure/ai/graphs/workspace_qa.py +++ b/src/noteflow/infrastructure/ai/graphs/workspace_qa.py @@ -2,8 +2,30 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import TYPE_CHECKING, Final, TypedDict +from typing import TYPE_CHECKING, Final, TypedDict, cast + +from noteflow.infrastructure.ai.constants import ( + NODE_RETRIEVE, + NODE_SYNTHESIZE, + NODE_VERIFY, + NODE_WEB_SEARCH, + NODE_WEB_SEARCH_APPROVAL, + STATE_ANSWER, + STATE_CITATIONS, + STATE_QUESTION, + STATE_RETRIEVED_SEGMENTS, + STATE_SUGGESTED_ANNOTATIONS, + STATE_VERIFICATION_PASSED, + STATE_WEB_CONTEXT, + STATE_WEB_SEARCH_APPROVED, +) +from noteflow.infrastructure.ai.graphs._shared import ( + build_no_information_payload, + connect_base_flow, + connect_web_search_flow, +) if TYPE_CHECKING: from uuid import UUID @@ -19,7 +41,7 @@ if TYPE_CHECKING: RetrievalResult, WorkspaceSegmentSearchProtocol, ) - from noteflow.infrastructure.ai.tools.synthesis import LLMProtocol + from noteflow.infrastructure.ai.tools.synthesis import LLMProtocol, SynthesisResult WORKSPACE_QA_GRAPH_NAME: Final[str] = "workspace_qa" WORKSPACE_QA_GRAPH_VERSION: Final[int] = 2 @@ -33,6 +55,14 @@ class WorkspaceQAConfig: require_annotation_approval: bool = False +@dataclass(frozen=True) +class WorkspaceQADependencies: + embedder: "EmbedderProtocol" + segment_repo: "WorkspaceSegmentSearchProtocol" + llm: "LLMProtocol" + web_search_provider: "WebSearchProvider | None" = None + + class WorkspaceQAInputState(TypedDict): question: str workspace_id: UUID @@ -54,145 +84,199 @@ class WorkspaceQAInternalState(WorkspaceQAInputState, WorkspaceQAOutputState): def build_workspace_qa_graph( - embedder: EmbedderProtocol, - segment_repo: WorkspaceSegmentSearchProtocol, - llm: LLMProtocol, + deps: WorkspaceQADependencies, *, - web_search_provider: WebSearchProvider | None = None, config: WorkspaceQAConfig | None = None, checkpointer: BaseCheckpointSaver[str] | None = None, ) -> CompiledStateGraph[WorkspaceQAInternalState]: - """Build Q&A graph for cross-meeting questions with segment citations. - - Graph flow (with web search): retrieve -> verify -> [web_search_approval] -> [web_search] -> synthesize - Graph flow (without): retrieve -> verify -> synthesize - - Args: - embedder: Protocol for generating text embeddings. - segment_repo: Protocol for workspace-scoped semantic segment search. - llm: Protocol for LLM text completion. - web_search_provider: Optional web search provider for augmentation. - config: Graph configuration for features/interrupts. - checkpointer: Optional checkpointer for interrupt support. - - Returns: - Compiled graph that accepts question/workspace_id and returns answer/citations. - """ + """Build Q&A graph for cross-meeting questions with segment citations.""" from langgraph.graph import END, START, StateGraph - from noteflow.domain.ai.citations import SegmentCitation - from noteflow.infrastructure.ai.interrupts import check_web_search_approval - from noteflow.infrastructure.ai.nodes.annotation_suggester import ( - extract_annotations_from_answer, - ) - from noteflow.infrastructure.ai.nodes.web_search import ( - WebSearchConfig, - derive_search_query, - execute_web_search, - format_results_for_context, - ) - from noteflow.infrastructure.ai.tools.retrieval import retrieve_segments_workspace - from noteflow.infrastructure.ai.tools.synthesis import synthesize_answer - effective_config = config or WorkspaceQAConfig() - - async def retrieve_node(state: WorkspaceQAInternalState) -> dict[str, object]: - results = await retrieve_segments_workspace( - query=state["question"], - embedder=embedder, - segment_repo=segment_repo, - workspace_id=state["workspace_id"], - project_id=state["project_id"], - top_k=state["top_k"], - ) - return {"retrieved_segments": results} - - async def verify_node(state: WorkspaceQAInternalState) -> dict[str, object]: - has_segments = len(state["retrieved_segments"]) > 0 - return {"verification_passed": has_segments} - - def web_search_approval_node(state: WorkspaceQAInternalState) -> dict[str, object]: - if not effective_config.enable_web_search or web_search_provider is None: - return {"web_search_approved": False} - - if not effective_config.require_web_approval: - return {"web_search_approved": True} - - query = derive_search_query(state["question"]) - approved = check_web_search_approval(query, require_approval=True) - return {"web_search_approved": approved} - - async def web_search_node(state: WorkspaceQAInternalState) -> dict[str, object]: - if not state.get("web_search_approved", False) or web_search_provider is None: - return {"web_context": ""} - - query = derive_search_query(state["question"]) - search_config = WebSearchConfig(enabled=True, require_approval=False) - response = await execute_web_search(query, web_search_provider, search_config) - context = format_results_for_context(response.results) - return {"web_context": context} - - async def synthesize_node(state: WorkspaceQAInternalState) -> dict[str, object]: - if not state["verification_passed"]: - return { - "answer": NO_INFORMATION_ANSWER, - "citations": [], - "suggested_annotations": [], - } - - result = await synthesize_answer( - question=state["question"], - segments=state["retrieved_segments"], - llm=llm, - ) - - citations = [ - SegmentCitation( - meeting_id=seg.meeting_id, - segment_id=seg.segment_id, - start_time=seg.start_time, - end_time=seg.end_time, - text=seg.text, - score=seg.score, - ) - for seg in state["retrieved_segments"] - if seg.segment_id in result.cited_segment_ids - ] - - suggested_annotations = extract_annotations_from_answer( - answer=result.answer, - cited_segment_ids=tuple(result.cited_segment_ids), - ) - - return { - "answer": result.answer, - "citations": citations, - "suggested_annotations": suggested_annotations, - } + nodes = _build_workspace_nodes(deps, effective_config) builder: StateGraph[WorkspaceQAInternalState] = StateGraph(WorkspaceQAInternalState) - builder.add_node("retrieve", retrieve_node) - builder.add_node("verify", verify_node) - builder.add_node("synthesize", synthesize_node) + builder.add_node(NODE_RETRIEVE, nodes.retrieve) + builder.add_node(NODE_VERIFY, nodes.verify) + builder.add_node(NODE_SYNTHESIZE, nodes.synthesize) - if effective_config.enable_web_search and web_search_provider is not None: - builder.add_node("web_search_approval", web_search_approval_node) - builder.add_node("web_search", web_search_node) - - builder.add_edge(START, "retrieve") - builder.add_edge("retrieve", "verify") - builder.add_edge("verify", "web_search_approval") - builder.add_edge("web_search_approval", "web_search") - builder.add_edge("web_search", "synthesize") - builder.add_edge("synthesize", END) + if nodes.web_search_approval is not None and nodes.web_search is not None: + builder.add_node(NODE_WEB_SEARCH_APPROVAL, nodes.web_search_approval) + builder.add_node(NODE_WEB_SEARCH, nodes.web_search) + connect_web_search_flow(builder, START, END) else: - builder.add_edge(START, "retrieve") - builder.add_edge("retrieve", "verify") - builder.add_edge("verify", "synthesize") - builder.add_edge("synthesize", END) + connect_base_flow(builder, START, END) compile_method = getattr(builder, "compile") compiled: CompiledStateGraph[WorkspaceQAInternalState] = compile_method( checkpointer=checkpointer ) return compiled + + +@dataclass(frozen=True) +class _WorkspaceQANodes: + retrieve: Callable[[WorkspaceQAInternalState], Awaitable[dict[str, object]]] + verify: Callable[[WorkspaceQAInternalState], Awaitable[dict[str, object]]] + synthesize: Callable[[WorkspaceQAInternalState], Awaitable[dict[str, object]]] + web_search_approval: Callable[[WorkspaceQAInternalState], Awaitable[dict[str, object]]] | None + web_search: Callable[[WorkspaceQAInternalState], Awaitable[dict[str, object]]] | None + + +def _build_workspace_nodes( + deps: WorkspaceQADependencies, + config: WorkspaceQAConfig, +) -> _WorkspaceQANodes: + allow_web_search = config.enable_web_search and deps.web_search_provider is not None + return _WorkspaceQANodes( + retrieve=_build_workspace_retrieve_node(deps), + verify=_build_workspace_verify_node(), + synthesize=_build_workspace_synthesize_node(deps), + web_search_approval=_build_workspace_web_search_approval_node(deps, config) + if allow_web_search + else None, + web_search=_build_workspace_web_search_node(deps, config) if allow_web_search else None, + ) + + +def _build_workspace_retrieve_node( + deps: WorkspaceQADependencies, +) -> Callable[[WorkspaceQAInternalState], Awaitable[dict[str, object]]]: + from noteflow.infrastructure.ai.tools.retrieval import ( + WorkspaceRetrievalDependencies, + WorkspaceRetrievalRequest, + retrieve_segments_workspace, + ) + + async def retrieve_node(state: WorkspaceQAInternalState) -> dict[str, object]: + question = cast(str, state[STATE_QUESTION]) + request = WorkspaceRetrievalRequest( + query=question, + workspace_id=state["workspace_id"], + project_id=state["project_id"], + top_k=state["top_k"], + ) + results = await retrieve_segments_workspace( + request, + WorkspaceRetrievalDependencies( + embedder=deps.embedder, + segment_repo=deps.segment_repo, + ), + ) + return {STATE_RETRIEVED_SEGMENTS: results} + + return retrieve_node + + +def _build_workspace_verify_node() -> Callable[[WorkspaceQAInternalState], Awaitable[dict[str, object]]]: + async def verify_node(state: WorkspaceQAInternalState) -> dict[str, object]: + segments = cast(list["RetrievalResult"], state[STATE_RETRIEVED_SEGMENTS]) + has_segments = len(segments) > 0 + return {STATE_VERIFICATION_PASSED: has_segments} + + return verify_node + + +def _build_workspace_web_search_approval_node( + deps: WorkspaceQADependencies, + config: WorkspaceQAConfig, +) -> Callable[[WorkspaceQAInternalState], Awaitable[dict[str, object]]]: + from noteflow.infrastructure.ai.interrupts import check_web_search_approval + from noteflow.infrastructure.ai.nodes.web_search import derive_search_query + + async def web_search_approval_node(state: WorkspaceQAInternalState) -> dict[str, object]: + if not config.require_web_approval: + return {STATE_WEB_SEARCH_APPROVED: True} + question = cast(str, state[STATE_QUESTION]) + query = derive_search_query(question) + approved = check_web_search_approval(query, require_approval=True) + return {STATE_WEB_SEARCH_APPROVED: approved} + + return web_search_approval_node + + +def _build_workspace_web_search_node( + deps: WorkspaceQADependencies, + config: WorkspaceQAConfig, +) -> Callable[[WorkspaceQAInternalState], Awaitable[dict[str, object]]]: + from noteflow.infrastructure.ai.nodes.web_search import ( + WebSearchConfig, + derive_search_query, + execute_web_search, + format_results_for_context, + ) + + async def web_search_node(state: WorkspaceQAInternalState) -> dict[str, object]: + approved = bool(state.get(STATE_WEB_SEARCH_APPROVED, False)) + if not approved or deps.web_search_provider is None: + return {STATE_WEB_CONTEXT: ""} + question = cast(str, state[STATE_QUESTION]) + query = derive_search_query(question) + search_config = WebSearchConfig(enabled=config.enable_web_search, require_approval=False) + response = await execute_web_search(query, deps.web_search_provider, search_config) + context = format_results_for_context(response.results) + return {STATE_WEB_CONTEXT: context} + + return web_search_node + + +def _build_workspace_synthesize_node( + deps: WorkspaceQADependencies, +) -> Callable[[WorkspaceQAInternalState], Awaitable[dict[str, object]]]: + from noteflow.infrastructure.ai.nodes.annotation_suggester import ( + extract_annotations_from_answer, + ) + from noteflow.infrastructure.ai.tools.synthesis import synthesize_answer + + async def synthesize_node(state: WorkspaceQAInternalState) -> dict[str, object]: + if not state[STATE_VERIFICATION_PASSED]: + return build_no_information_payload(NO_INFORMATION_ANSWER) + + question = cast(str, state[STATE_QUESTION]) + segments = cast(list["RetrievalResult"], state[STATE_RETRIEVED_SEGMENTS]) + result = await synthesize_answer( + question=question, + segments=segments, + llm=deps.llm, + ) + return _build_workspace_answer_payload( + retrieved_segments=segments, + result=result, + extract_annotations=extract_annotations_from_answer, + ) + + return synthesize_node + + +def _build_workspace_answer_payload( + *, + retrieved_segments: list["RetrievalResult"], + result: "SynthesisResult", + extract_annotations: Callable[[str, tuple[int, ...]], list["SuggestedAnnotation"]], +) -> dict[str, object]: + from noteflow.domain.ai.citations import SegmentCitation + + cited_ids = set(result.cited_segment_ids) + citations = [ + SegmentCitation( + meeting_id=seg.meeting_id, + segment_id=seg.segment_id, + start_time=seg.start_time, + end_time=seg.end_time, + text=seg.text, + score=seg.score, + ) + for seg in retrieved_segments + if seg.segment_id in cited_ids + ] + + suggested_annotations = extract_annotations( + result.answer, + tuple(result.cited_segment_ids), + ) + + return { + STATE_ANSWER: result.answer, + STATE_CITATIONS: citations, + STATE_SUGGESTED_ANNOTATIONS: suggested_annotations, + } diff --git a/src/noteflow/infrastructure/ai/guardrails.py b/src/noteflow/infrastructure/ai/guardrails.py index dfe448d..11ae77d 100644 --- a/src/noteflow/infrastructure/ai/guardrails.py +++ b/src/noteflow/infrastructure/ai/guardrails.py @@ -7,6 +7,7 @@ from dataclasses import dataclass, field from enum import Enum from typing import Final +from noteflow.domain.constants.fields import EMAIL from noteflow.infrastructure.logging import get_logger logger = get_logger(__name__) @@ -27,7 +28,7 @@ SSN_PATTERN: Final[re.Pattern[str]] = re.compile(r"\b\d{3}-\d{2}-\d{4}\b") CREDIT_CARD_PATTERN: Final[re.Pattern[str]] = re.compile(r"\b(?:\d{4}[-\s]?){3}\d{4}\b") PII_PATTERNS: Final[tuple[tuple[str, re.Pattern[str]], ...]] = ( - ("email", EMAIL_PATTERN), + (EMAIL, EMAIL_PATTERN), ("phone", PHONE_PATTERN), ("ssn", SSN_PATTERN), ("credit_card", CREDIT_CARD_PATTERN), @@ -35,6 +36,7 @@ PII_PATTERNS: Final[tuple[tuple[str, re.Pattern[str]], ...]] = ( # Redaction placeholder PII_REDACTION: Final[str] = "[REDACTED]" +CHARACTER_SUFFIX: Final[str] = " characters" class GuardrailViolation(str, Enum): @@ -57,33 +59,6 @@ class GuardrailResult: reason: str | None = None filtered_content: str | None = None - @staticmethod - def ok(content: str | None = None) -> GuardrailResult: - """Create a passing result.""" - return GuardrailResult(allowed=True, filtered_content=content) - - @staticmethod - def blocked( - violation: GuardrailViolation, - reason: str, - ) -> GuardrailResult: - """Create a blocking result.""" - return GuardrailResult(allowed=False, violation=violation, reason=reason) - - @staticmethod - def filtered( - content: str, - violation: GuardrailViolation, - reason: str, - ) -> GuardrailResult: - """Create a result with filtered content.""" - return GuardrailResult( - allowed=True, - violation=violation, - reason=reason, - filtered_content=content, - ) - @dataclass class GuardrailRules: @@ -126,22 +101,16 @@ def _check_length( ) -> GuardrailResult | None: """Check text length constraints.""" if is_input and len(text) < min_length: - return GuardrailResult.blocked( - GuardrailViolation.INPUT_TOO_SHORT, - f"Input must be at least {min_length} characters", - ) + reason = f"Input must be at least {min_length}{CHARACTER_SUFFIX}" + return _blocked_result(GuardrailViolation.INPUT_TOO_SHORT, reason) if is_input and len(text) > max_length: - return GuardrailResult.blocked( - GuardrailViolation.INPUT_TOO_LONG, - f"Input must be at most {max_length} characters", - ) + reason = f"Input must be at most {max_length}{CHARACTER_SUFFIX}" + return _blocked_result(GuardrailViolation.INPUT_TOO_LONG, reason) if not is_input and len(text) > max_length: - return GuardrailResult.blocked( - GuardrailViolation.OUTPUT_TOO_LONG, - f"Output exceeds {max_length} characters", - ) + reason = f"Output exceeds {max_length}{CHARACTER_SUFFIX}" + return _blocked_result(GuardrailViolation.OUTPUT_TOO_LONG, reason) return None @@ -155,7 +124,7 @@ def _check_blocked_phrases( for phrase in blocked_phrases: if phrase.lower() in text_lower: logger.warning("blocked_phrase_detected", phrase=phrase[:20]) - return GuardrailResult.blocked( + return _blocked_result( GuardrailViolation.BLOCKED_CONTENT, "Content contains blocked phrase", ) @@ -167,7 +136,7 @@ def _check_injection(text: str) -> GuardrailResult | None: for pattern in INJECTION_PATTERNS: if pattern.search(text): logger.warning("injection_attempt_detected") - return GuardrailResult.blocked( + return _blocked_result( GuardrailViolation.INJECTION_ATTEMPT, "Potential prompt injection detected", ) @@ -182,8 +151,7 @@ def _detect_pii(text: str) -> list[tuple[str, str]]: """ findings: list[tuple[str, str]] = [] for pii_type, pattern in PII_PATTERNS: - for match in pattern.finditer(text): - findings.append((pii_type, match.group())) + findings.extend((pii_type, match.group()) for match in pattern.finditer(text)) return findings @@ -205,7 +173,6 @@ async def check_input(text: str, rules: GuardrailRules) -> GuardrailResult: Returns: GuardrailResult indicating if input is allowed. """ - # Length checks length_result = _check_length( text, rules.min_input_length, @@ -215,39 +182,19 @@ async def check_input(text: str, rules: GuardrailRules) -> GuardrailResult: if length_result is not None: return length_result - # Blocked phrases phrase_result = _check_blocked_phrases(text, rules.blocked_phrases) if phrase_result is not None: return phrase_result - # Injection detection - if rules.detect_injection: - injection_result = _check_injection(text) - if injection_result is not None: - return injection_result + injection_result = _check_injection_if_enabled(text, rules.detect_injection) + if injection_result is not None: + return injection_result - # PII checks - if rules.block_pii or rules.redact_pii: - pii_findings = _detect_pii(text) - if pii_findings: - pii_types = [f[0] for f in pii_findings] - logger.info("pii_detected_in_input", pii_types=pii_types) + pii_result = _handle_pii_input(text, rules) + if pii_result is not None: + return pii_result - if rules.block_pii: - return GuardrailResult.blocked( - GuardrailViolation.CONTAINS_PII, - f"Input contains PII: {', '.join(pii_types)}", - ) - - # Redact instead of block - redacted = _redact_pii(text) - return GuardrailResult.filtered( - redacted, - GuardrailViolation.CONTAINS_PII, - f"PII redacted: {', '.join(pii_types)}", - ) - - return GuardrailResult.ok(text) + return _allowed_result(text) async def filter_output(text: str, rules: GuardrailRules) -> GuardrailResult: @@ -260,7 +207,6 @@ async def filter_output(text: str, rules: GuardrailRules) -> GuardrailResult: Returns: GuardrailResult with potentially filtered content. """ - # Length check length_result = _check_length( text, min_length=0, # No minimum for output @@ -268,45 +214,92 @@ async def filter_output(text: str, rules: GuardrailRules) -> GuardrailResult: is_input=False, ) if length_result is not None: - # Truncate instead of blocking for output truncated = text[: rules.max_output_length] - return GuardrailResult.filtered( + reason = f"Output truncated to {rules.max_output_length}{CHARACTER_SUFFIX}" + return _filtered_result( truncated, GuardrailViolation.OUTPUT_TOO_LONG, - f"Output truncated to {rules.max_output_length} characters", + reason, ) - # Blocked phrases in output phrase_result = _check_blocked_phrases(text, rules.blocked_phrases) if phrase_result is not None: return phrase_result - # PII redaction in output (always redact, never block output) - if rules.redact_pii: - pii_findings = _detect_pii(text) - if pii_findings: - pii_types = [f[0] for f in pii_findings] - logger.info("pii_detected_in_output", pii_types=pii_types) - redacted = _redact_pii(text) - return GuardrailResult.filtered( - redacted, - GuardrailViolation.CONTAINS_PII, - f"PII redacted: {', '.join(pii_types)}", - ) + if rules.redact_pii and (pii_findings := _detect_pii(text)): + pii_types = [f[0] for f in pii_findings] + logger.info("pii_detected_in_output", pii_types=pii_types) + redacted = _redact_pii(text) + reason = f"PII redacted: {', '.join(pii_types)}" + return _filtered_result(redacted, GuardrailViolation.CONTAINS_PII, reason) - return GuardrailResult.ok(text) + return _allowed_result(text) def create_default_rules() -> GuardrailRules: """Create default guardrail rules.""" - return GuardrailRules() + rules = GuardrailRules() + return rules def create_strict_rules() -> GuardrailRules: """Create strict guardrail rules with PII blocking.""" - return GuardrailRules( + rules = GuardrailRules( block_pii=True, redact_pii=False, detect_injection=True, max_input_length=2000, ) + return rules + + +def _blocked_result( + violation: GuardrailViolation, + reason: str, +) -> GuardrailResult: + return GuardrailResult(allowed=False, violation=violation, reason=reason) + + +def _filtered_result( + content: str, + violation: GuardrailViolation, + reason: str, +) -> GuardrailResult: + return GuardrailResult( + allowed=True, + violation=violation, + reason=reason, + filtered_content=content, + ) + + +def _allowed_result(content: str | None = None) -> GuardrailResult: + return GuardrailResult(allowed=True, filtered_content=content) + + +def _check_injection_if_enabled( + text: str, + detect_injection: bool, +) -> GuardrailResult | None: + if not detect_injection: + return None + return _check_injection(text) + + +def _handle_pii_input(text: str, rules: GuardrailRules) -> GuardrailResult | None: + if not (rules.block_pii or rules.redact_pii): + return None + pii_findings = _detect_pii(text) + if not pii_findings: + return None + + pii_types = [f[0] for f in pii_findings] + logger.info("pii_detected_in_input", pii_types=pii_types) + + if rules.block_pii: + reason = f"Input contains PII: {', '.join(pii_types)}" + return _blocked_result(GuardrailViolation.CONTAINS_PII, reason) + + redacted = _redact_pii(text) + reason = f"PII redacted: {', '.join(pii_types)}" + return _filtered_result(redacted, GuardrailViolation.CONTAINS_PII, reason) diff --git a/src/noteflow/infrastructure/ai/interrupts.py b/src/noteflow/infrastructure/ai/interrupts.py index 98a6f66..a2d56c4 100644 --- a/src/noteflow/infrastructure/ai/interrupts.py +++ b/src/noteflow/infrastructure/ai/interrupts.py @@ -5,17 +5,20 @@ Wraps LangGraph's interrupt() and Command APIs for consistent usage across graph from __future__ import annotations -from typing import TYPE_CHECKING, Final +from typing import TYPE_CHECKING, Any, Final, cast from uuid import uuid4 from langgraph.types import Command, interrupt +from noteflow.infrastructure.ai._langgraph_compat import create_command + from noteflow.domain.ai.interrupts import ( InterruptAction, InterruptResponse, create_annotation_interrupt, create_web_search_interrupt, ) +from noteflow.domain.constants.fields import ACTION from noteflow.infrastructure.logging import get_logger if TYPE_CHECKING: @@ -54,7 +57,7 @@ def request_web_search_approval( query_preview=query[:50], ) - response_data = interrupt(interrupt_request.to_dict()) + response_data = interrupt(interrupt_request.to_request_payload()) return _parse_interrupt_response(response_data, request_id) @@ -71,7 +74,7 @@ def request_annotation_approval( InterruptResponse with user's decision. """ request_id = str(uuid4()) - annotation_dicts = [ann.to_dict() for ann in annotations] + annotation_dicts = [ann.to_annotation_payload() for ann in annotations] interrupt_request = create_annotation_interrupt( annotations=annotation_dicts, request_id=request_id, @@ -83,7 +86,7 @@ def request_annotation_approval( annotation_count=len(annotations), ) - response_data = interrupt(interrupt_request.to_dict()) + response_data = interrupt(interrupt_request.to_request_payload()) return _parse_interrupt_response(response_data, request_id) @@ -106,16 +109,20 @@ def _parse_interrupt_response( return InterruptResponse(action=action, request_id=request_id) if isinstance(response_data, dict): - action_str = str(response_data.get("action", "reject")) + # Cast to typed dict after isinstance check for type narrowing + data: dict[str, object] = cast(dict[str, object], response_data) + action_str = str(data.get(ACTION, InterruptAction.REJECT.value)) action = _string_to_action(action_str) - modified_value = response_data.get("modified_value") - if modified_value is not None and not isinstance(modified_value, dict): - modified_value = None + modified_value_raw = data.get("modified_value") + modified_value: dict[str, object] | None = None + if isinstance(modified_value_raw, dict): + modified_value = cast(dict[str, object], modified_value_raw) - user_message = response_data.get("user_message") - if user_message is not None: - user_message = str(user_message) + user_message: str | None = None + user_message_raw = data.get("user_message") + if user_message_raw is not None: + user_message = str(user_message_raw) return InterruptResponse( action=action, @@ -135,14 +142,20 @@ def _parse_interrupt_response( def _string_to_action(value: str) -> InterruptAction: """Convert string response to InterruptAction.""" normalized = value.lower().strip() - if normalized in ("approve", "yes", "approved", "accept"): + approve_values = { + InterruptAction.APPROVE.value, + "yes", + "approved", + "accept", + } + if normalized in approve_values: return InterruptAction.APPROVE - if normalized in ("modify", "edit", "change"): + if normalized in {InterruptAction.MODIFY.value, "edit", "change"}: return InterruptAction.MODIFY return InterruptAction.REJECT -def create_resume_command(response: InterruptResponse) -> Command[None]: +def create_resume_command(response: InterruptResponse) -> Command[Any]: """Create a LangGraph Command to resume execution with user response. Args: @@ -151,7 +164,8 @@ def create_resume_command(response: InterruptResponse) -> Command[None]: Returns: Command to resume graph execution. """ - return Command(resume=response.to_dict()) + payload = response.to_response_payload() + return create_command(resume=payload) class InterruptHandler: @@ -166,6 +180,8 @@ class InterruptHandler: return self._require_web_approval def request_web_search(self, query: str) -> InterruptResponse: + if not self._require_web_approval: + return InterruptResponse(action=InterruptAction.APPROVE) return request_web_search_approval(query) def request_annotation_approval( @@ -214,18 +230,46 @@ def check_annotation_approval( if response.is_rejected: return False, [] - if response.is_modified and response.modified_value: - modified_list_raw = response.modified_value.get("annotations", []) - if isinstance(modified_list_raw, list): - from noteflow.infrastructure.ai.nodes.annotation_suggester import ( - SuggestedAnnotation, - ) - - modified_annotations: list[SuggestedAnnotation] = [] - for item in modified_list_raw: - if isinstance(item, dict): - item_dict: dict[str, object] = {str(k): v for k, v in item.items()} - modified_annotations.append(SuggestedAnnotation.from_dict(item_dict)) - return True, modified_annotations + modified = _extract_modified_annotations(response) + if modified is not None: + return True, modified return response.is_approved, annotations + + +def _extract_modified_annotations( + response: InterruptResponse, +) -> list[SuggestedAnnotation] | None: + if not response.is_modified: + return None + modified_value = response.modified_value + if not isinstance(modified_value, dict): + return None + raw_annotations = modified_value.get("annotations") + if not isinstance(raw_annotations, list): + return None + return _coerce_annotation_payloads(cast(list[object], raw_annotations)) + + +def _coerce_annotation_payloads( + raw_annotations: list[object], +) -> list[SuggestedAnnotation]: + from noteflow.infrastructure.ai.nodes.annotation_suggester import ( + SuggestedAnnotation, + ) + + modified_annotations: list[SuggestedAnnotation] = [] + for item in raw_annotations: + item_dict = _normalize_annotation_payload(item) + if item_dict is not None: + modified_annotations.append(SuggestedAnnotation.from_payload(item_dict)) + return modified_annotations + + +def _normalize_annotation_payload(item: object) -> dict[str, object] | None: + if not isinstance(item, dict): + return None + item_dict: dict[str, object] = {} + for key, value in cast(dict[object, object], item).items(): + item_dict[str(key)] = value + return item_dict diff --git a/src/noteflow/infrastructure/ai/nodes/annotation_suggester.py b/src/noteflow/infrastructure/ai/nodes/annotation_suggester.py index 2a3ef39..b748f82 100644 --- a/src/noteflow/infrastructure/ai/nodes/annotation_suggester.py +++ b/src/noteflow/infrastructure/ai/nodes/annotation_suggester.py @@ -5,13 +5,28 @@ from __future__ import annotations import re from dataclasses import dataclass from enum import Enum -from typing import Final +from itertools import chain +from typing import Final, cast + +from noteflow.domain.constants.fields import ( + ACTION_ITEM, + CONFIDENCE, + DECISION, + NOTE, + SEGMENT_IDS, + TEXT, +) + + +DEFAULT_CONFIDENCE: Final[float] = 0.8 +ACTION_ITEM_CONFIDENCE: Final[float] = 0.7 +DECISION_CONFIDENCE: Final[float] = 0.75 class SuggestedAnnotationType(str, Enum): - ACTION_ITEM = "action_item" - DECISION = "decision" - NOTE = "note" + ACTION_ITEM = ACTION_ITEM + DECISION = DECISION + NOTE = NOTE @dataclass(frozen=True) @@ -19,35 +34,23 @@ class SuggestedAnnotation: text: str annotation_type: SuggestedAnnotationType segment_ids: tuple[int, ...] - confidence: float = 0.8 + confidence: float = DEFAULT_CONFIDENCE - def to_dict(self) -> dict[str, object]: + def to_annotation_payload(self) -> dict[str, object]: return { - "text": self.text, + TEXT: self.text, "type": self.annotation_type.value, - "segment_ids": list(self.segment_ids), - "confidence": self.confidence, + SEGMENT_IDS: list(self.segment_ids), + CONFIDENCE: self.confidence, } @classmethod - def from_dict(cls, data: dict[str, object]) -> SuggestedAnnotation: - text = str(data.get("text", "")) - type_str = str(data.get("type", "note")) - segment_ids_raw = data.get("segment_ids", []) - if isinstance(segment_ids_raw, list): - segment_ids = tuple( - int(sid) for sid in segment_ids_raw if isinstance(sid, (int, float)) - ) - else: - segment_ids = () - confidence_raw = data.get("confidence", 0.8) - confidence = float(confidence_raw) if isinstance(confidence_raw, (int, float)) else 0.8 - - try: - annotation_type = SuggestedAnnotationType(type_str) - except ValueError: - annotation_type = SuggestedAnnotationType.NOTE - + def from_payload(cls, data: dict[str, object]) -> SuggestedAnnotation: + text = str(data.get(TEXT, "")) + type_str = str(data.get("type", NOTE)) + segment_ids = _parse_segment_ids(data.get(SEGMENT_IDS)) + confidence = _parse_confidence(data.get(CONFIDENCE)) + annotation_type = _parse_annotation_type(type_str) return cls( text=text, annotation_type=annotation_type, @@ -72,40 +75,92 @@ MIN_TEXT_LENGTH: Final[int] = 10 MAX_TEXT_LENGTH: Final[int] = 200 +@dataclass(frozen=True) +class SuggestionCriteria: + patterns: tuple[re.Pattern[str], ...] + annotation_type: SuggestedAnnotationType + confidence: float + segment_ids: tuple[int, ...] + + def extract_annotations_from_answer( answer: str, cited_segment_ids: tuple[int, ...], ) -> list[SuggestedAnnotation]: """Extract action items and decisions from synthesized answer.""" + action_criteria = SuggestionCriteria( + patterns=ACTION_ITEM_PATTERNS, + annotation_type=SuggestedAnnotationType.ACTION_ITEM, + confidence=ACTION_ITEM_CONFIDENCE, + segment_ids=cited_segment_ids, + ) + decision_criteria = SuggestionCriteria( + patterns=DECISION_PATTERNS, + annotation_type=SuggestedAnnotationType.DECISION, + confidence=DECISION_CONFIDENCE, + segment_ids=cited_segment_ids, + ) + action_suggestions = _collect_suggestions(answer, action_criteria) + decision_suggestions = _collect_suggestions(answer, decision_criteria) + + return _dedupe_suggestions(action_suggestions + decision_suggestions) + + +def _collect_suggestions( + answer: str, + criteria: SuggestionCriteria, +) -> list[SuggestedAnnotation]: suggestions: list[SuggestedAnnotation] = [] - - for pattern in ACTION_ITEM_PATTERNS: - for match in pattern.finditer(answer): - text = match.group(1).strip() - if MIN_TEXT_LENGTH <= len(text) <= MAX_TEXT_LENGTH: - suggestions.append( - SuggestedAnnotation( - text=text, - annotation_type=SuggestedAnnotationType.ACTION_ITEM, - segment_ids=cited_segment_ids, - confidence=0.7, - ) + for match in chain.from_iterable( + pattern.finditer(answer) for pattern in criteria.patterns + ): + text = match.group(1).strip() + if _is_valid_suggestion_text(text): + suggestions.append( + SuggestedAnnotation( + text=text, + annotation_type=criteria.annotation_type, + segment_ids=criteria.segment_ids, + confidence=criteria.confidence, ) + ) + return suggestions - for pattern in DECISION_PATTERNS: - for match in pattern.finditer(answer): - text = match.group(1).strip() - if MIN_TEXT_LENGTH <= len(text) <= MAX_TEXT_LENGTH: - suggestions.append( - SuggestedAnnotation( - text=text, - annotation_type=SuggestedAnnotationType.DECISION, - segment_ids=cited_segment_ids, - confidence=0.75, - ) - ) - return _dedupe_suggestions(suggestions) +def _is_valid_suggestion_text(text: str) -> bool: + return MIN_TEXT_LENGTH <= len(text) <= MAX_TEXT_LENGTH + + +def _parse_segment_ids(value: object) -> tuple[int, ...]: + if not isinstance(value, list): + return () + segment_ids_list = [ + converted + for raw_sid in cast(list[object], value) + if (converted := _coerce_segment_id(raw_sid)) is not None + ] + return tuple(segment_ids_list) + + +def _parse_confidence(value: object) -> float: + if isinstance(value, (int, float)): + return float(value) + return DEFAULT_CONFIDENCE + + +def _coerce_segment_id(value: object) -> int | None: + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + return None + + +def _parse_annotation_type(type_str: str) -> SuggestedAnnotationType: + try: + return SuggestedAnnotationType(type_str) + except ValueError: + return SuggestedAnnotationType.NOTE def _dedupe_suggestions(suggestions: list[SuggestedAnnotation]) -> list[SuggestedAnnotation]: diff --git a/src/noteflow/infrastructure/ai/nodes/web_search.py b/src/noteflow/infrastructure/ai/nodes/web_search.py index a070c55..7c8c909 100644 --- a/src/noteflow/infrastructure/ai/nodes/web_search.py +++ b/src/noteflow/infrastructure/ai/nodes/web_search.py @@ -22,7 +22,7 @@ class WebSearchResult: snippet: str score: float = 1.0 - def to_dict(self) -> dict[str, object]: + def to_result_payload(self) -> dict[str, object]: """Convert to dictionary for serialization.""" return { "title": self.title, diff --git a/src/noteflow/infrastructure/ai/tools/retrieval.py b/src/noteflow/infrastructure/ai/tools/retrieval.py index 4795e99..fd24779 100644 --- a/src/noteflow/infrastructure/ai/tools/retrieval.py +++ b/src/noteflow/infrastructure/ai/tools/retrieval.py @@ -73,6 +73,48 @@ class RetrievalResult: score: float +@dataclass(frozen=True) +class MeetingRetrievalRequest: + query: str + meeting_id: MeetingId | None = None + top_k: int = 8 + + +@dataclass(frozen=True) +class WorkspaceRetrievalRequest: + query: str + workspace_id: UUID + project_id: UUID | None = None + top_k: int = 20 + + +@dataclass(frozen=True) +class MeetingBatchRetrievalRequest: + queries: Sequence[str] + meeting_id: MeetingId | None = None + top_k: int = 8 + + +@dataclass(frozen=True) +class WorkspaceBatchRetrievalRequest: + queries: Sequence[str] + workspace_id: UUID + project_id: UUID | None = None + top_k: int = 20 + + +@dataclass(frozen=True) +class MeetingRetrievalDependencies: + embedder: EmbedderProtocol + segment_repo: SegmentSearchProtocol + + +@dataclass(frozen=True) +class WorkspaceRetrievalDependencies: + embedder: EmbedderProtocol + segment_repo: WorkspaceSegmentSearchProtocol + + def _meeting_id_to_uuid(mid: MeetingId | None) -> UUID: if mid is None: msg = "meeting_id is required for RetrievalResult" @@ -80,20 +122,9 @@ def _meeting_id_to_uuid(mid: MeetingId | None) -> UUID: return UUID(str(mid)) -async def retrieve_segments( - query: str, - embedder: EmbedderProtocol, - segment_repo: SegmentSearchProtocol, - meeting_id: MeetingId | None = None, - top_k: int = 8, +def _build_retrieval_results( + results: Sequence[tuple[SegmentLike, float]], ) -> list[RetrievalResult]: - """Retrieve relevant transcript segments via semantic search.""" - query_embedding = await embedder.embed(query) - results = await segment_repo.search_semantic( - query_embedding=query_embedding, - limit=top_k, - meeting_id=meeting_id, - ) return [ RetrievalResult( segment_id=segment.segment_id, @@ -107,33 +138,33 @@ async def retrieve_segments( ] +async def retrieve_segments( + request: MeetingRetrievalRequest, + deps: MeetingRetrievalDependencies, +) -> list[RetrievalResult]: + """Retrieve relevant transcript segments via semantic search.""" + query_embedding = await deps.embedder.embed(request.query) + results = await deps.segment_repo.search_semantic( + query_embedding=query_embedding, + limit=request.top_k, + meeting_id=request.meeting_id, + ) + return _build_retrieval_results(results) + + async def retrieve_segments_workspace( - query: str, - embedder: EmbedderProtocol, - segment_repo: WorkspaceSegmentSearchProtocol, - workspace_id: UUID, - project_id: UUID | None = None, - top_k: int = 20, + request: WorkspaceRetrievalRequest, + deps: WorkspaceRetrievalDependencies, ) -> list[RetrievalResult]: """Retrieve relevant transcript segments across workspace/project via semantic search.""" - query_embedding = await embedder.embed(query) - results = await segment_repo.search_semantic_workspace( + query_embedding = await deps.embedder.embed(request.query) + results = await deps.segment_repo.search_semantic_workspace( query_embedding=query_embedding, - workspace_id=workspace_id, - project_id=project_id, - limit=top_k, + workspace_id=request.workspace_id, + project_id=request.project_id, + limit=request.top_k, ) - return [ - RetrievalResult( - segment_id=segment.segment_id, - meeting_id=_meeting_id_to_uuid(segment.meeting_id), - text=segment.text, - start_time=segment.start_time, - end_time=segment.end_time, - score=score, - ) - for segment, score in results - ] + return _build_retrieval_results(results) async def _embed_batch_fallback( @@ -154,84 +185,57 @@ async def _embed_batch_fallback( async def retrieve_segments_batch( - queries: Sequence[str], - embedder: EmbedderProtocol, - segment_repo: SegmentSearchProtocol, - meeting_id: MeetingId | None = None, - top_k: int = 8, + request: MeetingBatchRetrievalRequest, + deps: MeetingRetrievalDependencies, ) -> list[list[RetrievalResult]]: """Retrieve segments for multiple queries in parallel. Uses batch embedding when available, then parallel search execution. Returns results in the same order as input queries. """ - if not queries: + if not request.queries: return [] - embeddings = await _embed_batch_fallback(list(queries), embedder) + embeddings = await _embed_batch_fallback(list(request.queries), deps.embedder) semaphore = asyncio.Semaphore(MAX_CONCURRENT_OPERATIONS) async def _search(emb: list[float]) -> list[RetrievalResult]: async with semaphore: - results = await segment_repo.search_semantic( + results = await deps.segment_repo.search_semantic( query_embedding=emb, - limit=top_k, - meeting_id=meeting_id, + limit=request.top_k, + meeting_id=request.meeting_id, ) - return [ - RetrievalResult( - segment_id=seg.segment_id, - meeting_id=_meeting_id_to_uuid(seg.meeting_id), - text=seg.text, - start_time=seg.start_time, - end_time=seg.end_time, - score=score, - ) - for seg, score in results - ] + return _build_retrieval_results(results) search_results = await asyncio.gather(*(_search(emb) for emb in embeddings)) return list(search_results) async def retrieve_segments_workspace_batch( - queries: Sequence[str], - embedder: EmbedderProtocol, - segment_repo: WorkspaceSegmentSearchProtocol, - workspace_id: UUID, - project_id: UUID | None = None, - top_k: int = 20, + request: WorkspaceBatchRetrievalRequest, + deps: WorkspaceRetrievalDependencies, ) -> list[list[RetrievalResult]]: """Retrieve workspace segments for multiple queries in parallel. Uses batch embedding when available, then parallel search execution. Returns results in the same order as input queries. """ - if not queries: + if not request.queries: return [] - embeddings = await _embed_batch_fallback(list(queries), embedder) + embeddings = await _embed_batch_fallback(list(request.queries), deps.embedder) semaphore = asyncio.Semaphore(MAX_CONCURRENT_OPERATIONS) async def _search(emb: list[float]) -> list[RetrievalResult]: async with semaphore: - results = await segment_repo.search_semantic_workspace( + results = await deps.segment_repo.search_semantic_workspace( query_embedding=emb, - workspace_id=workspace_id, - project_id=project_id, - limit=top_k, + workspace_id=request.workspace_id, + project_id=request.project_id, + limit=request.top_k, ) - return [ - RetrievalResult( - segment_id=seg.segment_id, - meeting_id=_meeting_id_to_uuid(seg.meeting_id), - text=seg.text, - start_time=seg.start_time, - end_time=seg.end_time, - score=score, - ) - for seg, score in results - ] + return _build_retrieval_results(results) search_results = await asyncio.gather(*(_search(emb) for emb in embeddings)) return list(search_results) diff --git a/src/noteflow/infrastructure/ai/tools/synthesis.py b/src/noteflow/infrastructure/ai/tools/synthesis.py index fbbff52..06abbba 100644 --- a/src/noteflow/infrastructure/ai/tools/synthesis.py +++ b/src/noteflow/infrastructure/ai/tools/synthesis.py @@ -20,18 +20,6 @@ class SynthesisResult: cited_segment_ids: list[int] -SYNTHESIS_PROMPT_TEMPLATE: Final[ - str -] = """Answer the question based on the following transcript segments. -Cite specific segments by their ID when making claims. - -Question: {question} - -Segments: -{segments} - -Answer (cite segment IDs in brackets like [1], [3]):""" - CITATION_PATTERN: Final[re.Pattern[str]] = re.compile(r"\[(\d+)\]") @@ -44,10 +32,7 @@ async def synthesize_answer( segment_text = "\n".join( f"[{s.segment_id}] ({s.start_time:.1f}s-{s.end_time:.1f}s): {s.text}" for s in segments ) - prompt = SYNTHESIS_PROMPT_TEMPLATE.format( - question=question, - segments=segment_text, - ) + prompt = _build_prompt(question, segment_text) answer = await llm.complete(prompt) valid_ids = {s.segment_id for s in segments} cited_ids = extract_cited_ids(answer, valid_ids) @@ -58,3 +43,13 @@ def extract_cited_ids(answer: str, valid_ids: set[int]) -> list[int]: matches = CITATION_PATTERN.findall(answer) cited = [int(m) for m in matches if int(m) in valid_ids] return list(dict.fromkeys(cited)) + + +def _build_prompt(question: str, segments: str) -> str: + return ( + "Answer the question based on the following transcript segments.\n" + "Cite specific segments by their ID when making claims.\n\n" + f"Question: {question}\n\n" + f"Segments:\n{segments}\n\n" + "Answer (cite segment IDs in brackets like [1], [3]):" + ) diff --git a/src/noteflow/infrastructure/converters/integration_converters.py b/src/noteflow/infrastructure/converters/integration_converters.py index f9b8d08..eebf369 100644 --- a/src/noteflow/infrastructure/converters/integration_converters.py +++ b/src/noteflow/infrastructure/converters/integration_converters.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -from noteflow.domain.constants.fields import DURATION_MS, ENDED_AT, ERROR_CODE +from noteflow.domain.constants.fields import CONFIG, DURATION_MS, ENDED_AT, ERROR_CODE from noteflow.domain.entities.integration import ( Integration, IntegrationStatus, @@ -47,7 +47,7 @@ class IntegrationConverter: ) @staticmethod - def to_orm_kwargs(entity: Integration) -> dict[str, object]: + def to_integration_orm_kwargs(entity: Integration) -> dict[str, object]: """Convert domain entity to ORM model kwargs. Returns a dict of kwargs rather than instantiating IntegrationModel @@ -66,7 +66,7 @@ class IntegrationConverter: "name": entity.name, "type": entity.type.value, "status": entity.status.value, - "config": entity.config, + CONFIG: entity.config, "last_sync": entity.last_sync, "error_message": entity.error_message, "created_at": entity.created_at, @@ -105,7 +105,7 @@ class SyncRunConverter: ) @staticmethod - def to_orm_kwargs(entity: SyncRun) -> dict[str, object]: + def to_sync_run_orm_kwargs(entity: SyncRun) -> dict[str, object]: """Convert domain entity to ORM model kwargs. Args: diff --git a/src/noteflow/infrastructure/converters/ner_converters.py b/src/noteflow/infrastructure/converters/ner_converters.py index dad79ee..4c48adc 100644 --- a/src/noteflow/infrastructure/converters/ner_converters.py +++ b/src/noteflow/infrastructure/converters/ner_converters.py @@ -53,7 +53,7 @@ class NerConverter: ) @staticmethod - def to_orm_kwargs(entity: NamedEntity) -> dict[str, object]: + def to_entity_orm_kwargs(entity: NamedEntity) -> dict[str, object]: """Convert domain entity to ORM model kwargs. Returns a dict of kwargs rather than instantiating NamedEntityModel diff --git a/src/noteflow/infrastructure/metrics/system_resources.py b/src/noteflow/infrastructure/metrics/system_resources.py index 450e21f..832f072 100644 --- a/src/noteflow/infrastructure/metrics/system_resources.py +++ b/src/noteflow/infrastructure/metrics/system_resources.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from typing import Final, Protocol, cast @@ -17,7 +18,7 @@ class _TorchCudaProperties(Protocol): class _TorchCudaModule(Protocol): - def is_available(self) -> bool: ... + is_available: Callable[[], bool] def mem_get_info(self) -> tuple[int, int]: ... def get_device_properties(self, device: int) -> _TorchCudaProperties: ... def memory_reserved(self, device: int) -> int: ... diff --git a/src/noteflow/infrastructure/ner/backends/gliner_backend.py b/src/noteflow/infrastructure/ner/backends/gliner_backend.py index 37f5b3a..25ff853 100644 --- a/src/noteflow/infrastructure/ner/backends/gliner_backend.py +++ b/src/noteflow/infrastructure/ner/backends/gliner_backend.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Final, TypedDict, cast from noteflow.domain.entities.named_entity import EntityCategory from noteflow.infrastructure.logging import get_logger, log_timing +from noteflow.infrastructure.ner.constants import LABEL_TIME from noteflow.infrastructure.ner.backends.types import RawEntity if TYPE_CHECKING: @@ -31,7 +32,7 @@ MEETING_LABELS: Final[tuple[str, ...]] = ( EntityCategory.PRODUCT.value, "app", EntityCategory.LOCATION.value, - "time", + LABEL_TIME, EntityCategory.TIME_RELATIVE.value, EntityCategory.DURATION.value, EntityCategory.EVENT.value, diff --git a/src/noteflow/infrastructure/ner/constants.py b/src/noteflow/infrastructure/ner/constants.py new file mode 100644 index 0000000..f38ac38 --- /dev/null +++ b/src/noteflow/infrastructure/ner/constants.py @@ -0,0 +1,7 @@ +"""Constants for NER label normalization.""" + +from __future__ import annotations + +from typing import Final + +LABEL_TIME: Final[str] = "time" diff --git a/src/noteflow/infrastructure/ner/mapper.py b/src/noteflow/infrastructure/ner/mapper.py index e0c32fa..676719e 100644 --- a/src/noteflow/infrastructure/ner/mapper.py +++ b/src/noteflow/infrastructure/ner/mapper.py @@ -5,6 +5,7 @@ from __future__ import annotations from typing import Final from noteflow.domain.entities.named_entity import EntityCategory, NamedEntity +from noteflow.infrastructure.ner.constants import LABEL_TIME from noteflow.infrastructure.ner.backends.types import RawEntity DEFAULT_CONFIDENCE: Final[float] = 0.8 @@ -20,7 +21,7 @@ LABEL_TO_CATEGORY: Final[dict[str, EntityCategory]] = { "loc": EntityCategory.LOCATION, "fac": EntityCategory.LOCATION, EntityCategory.DATE.value: EntityCategory.DATE, - "time": EntityCategory.DATE, + LABEL_TIME: EntityCategory.DATE, EntityCategory.TIME_RELATIVE.value: EntityCategory.TIME_RELATIVE, EntityCategory.DURATION.value: EntityCategory.DURATION, EntityCategory.EVENT.value: EntityCategory.EVENT, diff --git a/src/noteflow/infrastructure/ner/post_processing.py b/src/noteflow/infrastructure/ner/post_processing.py index bd17411..7b9ef63 100644 --- a/src/noteflow/infrastructure/ner/post_processing.py +++ b/src/noteflow/infrastructure/ner/post_processing.py @@ -6,6 +6,7 @@ import re from typing import Final from noteflow.domain.constants.fields import DATE +from noteflow.infrastructure.ner.constants import LABEL_TIME from noteflow.infrastructure.ner.backends.types import RawEntity PROFANITY_WORDS: Final[frozenset[str]] = frozenset( @@ -41,7 +42,7 @@ DURATION_UNITS: Final[frozenset[str]] = frozenset( TIME_LABELS: Final[frozenset[str]] = frozenset( { - "time", + LABEL_TIME, "time_relative", DATE, } diff --git a/src/noteflow/infrastructure/persistence/repositories/_materialized_view_queries.py b/src/noteflow/infrastructure/persistence/repositories/_materialized_view_queries.py index 2ef6bfb..6054f92 100644 --- a/src/noteflow/infrastructure/persistence/repositories/_materialized_view_queries.py +++ b/src/noteflow/infrastructure/persistence/repositories/_materialized_view_queries.py @@ -42,19 +42,29 @@ MaterializedViewName = Literal[ "mv_entity_totals", ] +# Canonical view name constants +MV_DAILY_MEETING_STATS: Final[MaterializedViewName] = "mv_daily_meeting_stats" +MV_SPEAKER_STATS: Final[MaterializedViewName] = "mv_speaker_stats" +MV_ENTITY_CATEGORY_STATS: Final[MaterializedViewName] = "mv_entity_category_stats" +MV_TOP_ENTITIES: Final[MaterializedViewName] = "mv_top_entities" +MV_MEETING_TOTALS: Final[MaterializedViewName] = "mv_meeting_totals" +MV_ENTITY_TOTALS: Final[MaterializedViewName] = "mv_entity_totals" + # Frozen set for runtime validation VALID_VIEW_NAMES: Final[frozenset[str]] = frozenset(get_args(MaterializedViewName)) # Ordered list of all materialized views ALL_MATERIALIZED_VIEWS: Final[tuple[MaterializedViewName, ...]] = ( - "mv_daily_meeting_stats", - "mv_speaker_stats", - "mv_entity_category_stats", - "mv_top_entities", - "mv_meeting_totals", - "mv_entity_totals", + MV_DAILY_MEETING_STATS, + MV_SPEAKER_STATS, + MV_ENTITY_CATEGORY_STATS, + MV_TOP_ENTITIES, + MV_MEETING_TOTALS, + MV_ENTITY_TOTALS, ) +SQL_AND: Final[str] = " AND " + class DailyStatsMVRow(Protocol): meeting_date: date @@ -174,7 +184,7 @@ async def fetch_daily_stats_mv( conditions, query_params = _build_daily_params( params.workspace_id, params.project_ids, params.start_time, params.end_time ) - where_clause = " AND ".join(conditions) + where_clause = SQL_AND.join(conditions) query = text(f""" SELECT meeting_date, @@ -199,7 +209,7 @@ async def fetch_meeting_totals_mv( return None conditions, query_params = _build_workspace_params(params.workspace_id, params.project_ids) - where_clause = " AND ".join(conditions) + where_clause = SQL_AND.join(conditions) query = text(f""" SELECT SUM(total_meetings)::integer as total_meetings, @@ -226,7 +236,7 @@ async def fetch_speaker_stats_mv( return None conditions, query_params = _build_workspace_params(params.workspace_id, params.project_ids) - where_clause = " AND ".join(conditions) + where_clause = SQL_AND.join(conditions) query = text(f""" SELECT speaker_id, @@ -252,7 +262,7 @@ async def fetch_entity_category_mv( return None conditions, query_params = _build_workspace_params(params.workspace_id, params.project_ids) - where_clause = " AND ".join(conditions) + where_clause = SQL_AND.join(conditions) query = text(f""" SELECT category, @@ -279,7 +289,7 @@ async def fetch_top_entities_mv( conditions, query_params = _build_workspace_params(params.workspace_id, params.project_ids) query_params["limit"] = limit - where_clause = " AND ".join(conditions) + where_clause = SQL_AND.join(conditions) query = text(f""" SELECT text, @@ -305,7 +315,7 @@ async def fetch_entity_totals_mv( return None conditions, query_params = _build_workspace_params(params.workspace_id, params.project_ids) - where_clause = " AND ".join(conditions) + where_clause = SQL_AND.join(conditions) query = text(f""" SELECT SUM(total_entities)::integer as total_entities, diff --git a/src/noteflow/infrastructure/persistence/repositories/analytics_repo.py b/src/noteflow/infrastructure/persistence/repositories/analytics_repo.py index 16486bf..6862a5d 100644 --- a/src/noteflow/infrastructure/persistence/repositories/analytics_repo.py +++ b/src/noteflow/infrastructure/persistence/repositories/analytics_repo.py @@ -45,6 +45,12 @@ from noteflow.infrastructure.persistence.repositories._analytics_converters impo top_entity_from_row, ) from noteflow.infrastructure.persistence.repositories._materialized_view_queries import ( + MV_DAILY_MEETING_STATS, + MV_ENTITY_CATEGORY_STATS, + MV_ENTITY_TOTALS, + MV_MEETING_TOTALS, + MV_SPEAKER_STATS, + MV_TOP_ENTITIES, fetch_daily_stats_mv, fetch_entity_category_mv, fetch_entity_totals_mv, @@ -61,6 +67,8 @@ logger = get_logger(__name__) class SqlAlchemyAnalyticsRepository(BaseRepository): _mv_available: bool | None = None + _ANALYTICS_MV_HIT = "analytics_mv_hit" + _ANALYTICS_MV_FALLBACK = "analytics_mv_fallback" @classmethod def reset_mv_cache(cls) -> None: @@ -81,14 +89,14 @@ class SqlAlchemyAnalyticsRepository(BaseRepository): await refresh_all_materialized_views(self._session) async def refresh_meeting_views(self) -> None: - await refresh_materialized_view(self._session, "mv_daily_meeting_stats") - await refresh_materialized_view(self._session, "mv_meeting_totals") - await refresh_materialized_view(self._session, "mv_speaker_stats") + await refresh_materialized_view(self._session, MV_DAILY_MEETING_STATS) + await refresh_materialized_view(self._session, MV_MEETING_TOTALS) + await refresh_materialized_view(self._session, MV_SPEAKER_STATS) async def refresh_entity_views(self) -> None: - await refresh_materialized_view(self._session, "mv_entity_category_stats") - await refresh_materialized_view(self._session, "mv_top_entities") - await refresh_materialized_view(self._session, "mv_entity_totals") + await refresh_materialized_view(self._session, MV_ENTITY_CATEGORY_STATS) + await refresh_materialized_view(self._session, MV_TOP_ENTITIES) + await refresh_materialized_view(self._session, MV_ENTITY_TOTALS) async def get_overview( self, @@ -201,9 +209,9 @@ class SqlAlchemyAnalyticsRepository(BaseRepository): ) mv_result = await self._get_overview_from_mv(params) if mv_result is not None: - logger.debug("analytics_mv_hit", method="get_overview") + logger.debug(self._ANALYTICS_MV_HIT, method="get_overview") return mv_result - logger.debug("analytics_mv_fallback", method="get_overview") + logger.debug(self._ANALYTICS_MV_FALLBACK, method="get_overview") return await self.get_overview(workspace_id, project_ids, start_time, end_time) @@ -243,9 +251,9 @@ class SqlAlchemyAnalyticsRepository(BaseRepository): ) mv_result = await fetch_speaker_stats_mv(self._session, params) if mv_result is not None: - logger.debug("analytics_mv_hit", method="get_speaker_stats") + logger.debug(self._ANALYTICS_MV_HIT, method="get_speaker_stats") return [speaker_stat_from_mv(r) for r in mv_result] - logger.debug("analytics_mv_fallback", method="get_speaker_stats") + logger.debug(self._ANALYTICS_MV_FALLBACK, method="get_speaker_stats") return await self.get_speaker_stats(workspace_id, project_ids, start_time, end_time) @@ -264,9 +272,9 @@ class SqlAlchemyAnalyticsRepository(BaseRepository): base_params, top_limit=params.top_limit ) if mv_result is not None: - logger.debug("analytics_mv_hit", method="get_entity_analytics") + logger.debug(self._ANALYTICS_MV_HIT, method="get_entity_analytics") return mv_result - logger.debug("analytics_mv_fallback", method="get_entity_analytics") + logger.debug(self._ANALYTICS_MV_FALLBACK, method="get_entity_analytics") return await self.get_entity_analytics(params) diff --git a/src/noteflow/infrastructure/persistence/repositories/entity_repo.py b/src/noteflow/infrastructure/persistence/repositories/entity_repo.py index 439c204..d41e3ae 100644 --- a/src/noteflow/infrastructure/persistence/repositories/entity_repo.py +++ b/src/noteflow/infrastructure/persistence/repositories/entity_repo.py @@ -62,7 +62,7 @@ class SqlAlchemyEntityRepository( Returns: Saved entity with db_id populated. """ - kwargs = NerConverter.to_orm_kwargs(entity) + kwargs = NerConverter.to_entity_orm_kwargs(entity) stmt = insert(NamedEntityModel).values(**kwargs) excluded = stmt.excluded stmt = stmt.on_conflict_do_update( @@ -103,7 +103,7 @@ class SqlAlchemyEntityRepository( if not entities: return entities - payload = [NerConverter.to_orm_kwargs(entity) for entity in entities] + payload = [NerConverter.to_entity_orm_kwargs(entity) for entity in entities] stmt = insert(NamedEntityModel).values(payload) excluded = stmt.excluded stmt = stmt.on_conflict_do_update( diff --git a/src/noteflow/infrastructure/persistence/repositories/integration/integration_repo.py b/src/noteflow/infrastructure/persistence/repositories/integration/integration_repo.py index 144e517..2e3e8b1 100644 --- a/src/noteflow/infrastructure/persistence/repositories/integration/integration_repo.py +++ b/src/noteflow/infrastructure/persistence/repositories/integration/integration_repo.py @@ -90,7 +90,7 @@ class SqlAlchemyIntegrationRepository( Returns: Created integration. """ - kwargs = IntegrationConverter.to_orm_kwargs(integration) + kwargs = IntegrationConverter.to_integration_orm_kwargs(integration) model = IntegrationModel(**kwargs) await self._add_and_flush(model) logger.info( @@ -207,7 +207,7 @@ class SqlAlchemyIntegrationRepository( Returns: Created sync run. """ - kwargs = SyncRunConverter.to_orm_kwargs(sync_run) + kwargs = SyncRunConverter.to_sync_run_orm_kwargs(sync_run) model = IntegrationSyncRunModel(**kwargs) await self._add_and_flush(model) logger.info( diff --git a/src/noteflow/infrastructure/persistence/repositories/usage_event/_aggregations.py b/src/noteflow/infrastructure/persistence/repositories/usage_event/_aggregations.py index 310fc4c..dcd4d24 100644 --- a/src/noteflow/infrastructure/persistence/repositories/usage_event/_aggregations.py +++ b/src/noteflow/infrastructure/persistence/repositories/usage_event/_aggregations.py @@ -12,7 +12,7 @@ from sqlalchemy import func, select from sqlalchemy.engine import RowMapping from sqlalchemy.ext.asyncio import AsyncSession -from noteflow.domain.constants.fields import MODEL_NAME +from noteflow.domain.constants.fields import COUNT, MODEL_NAME from noteflow.infrastructure.persistence.models.observability.usage_event import ( UsageEventModel, ) @@ -182,7 +182,7 @@ async def count_by_event_type( stmt = ( select( UsageEventModel.event_type, - func.count(UsageEventModel.id).label("count"), + func.count(UsageEventModel.id).label(COUNT), ) .where( UsageEventModel.timestamp >= start_time, @@ -193,4 +193,4 @@ async def count_by_event_type( result = await session.execute(stmt) rows: Sequence[RowMapping] = result.mappings().all() - return {str(row["event_type"]): int(row["count"]) for row in rows} + return {str(row["event_type"]): int(row[COUNT]) for row in rows} diff --git a/src/noteflow/infrastructure/summarization/_action_items.py b/src/noteflow/infrastructure/summarization/_action_items.py index 352482f..54ee58f 100644 --- a/src/noteflow/infrastructure/summarization/_action_items.py +++ b/src/noteflow/infrastructure/summarization/_action_items.py @@ -5,11 +5,12 @@ from __future__ import annotations from collections.abc import Sequence from typing import Final +from noteflow.domain.constants.fields import ACTION from noteflow.domain.entities import ActionItem, Segment _ACTION_KEYWORDS: Final[tuple[str, ...]] = ( "todo", - "action", + ACTION, "will", "should", "must", diff --git a/src/noteflow/infrastructure/summarization/template_renderer.py b/src/noteflow/infrastructure/summarization/template_renderer.py index 510b8db..f08bb88 100644 --- a/src/noteflow/infrastructure/summarization/template_renderer.py +++ b/src/noteflow/infrastructure/summarization/template_renderer.py @@ -8,7 +8,7 @@ from dataclasses import dataclass, field from datetime import datetime from typing import TypeVar -from noteflow.domain.constants.fields import DISPLAY_NAME, ENDED_AT, STATE +from noteflow.domain.constants.fields import DISPLAY_NAME, EMAIL, ENDED_AT, STATE from noteflow.domain.constants.placeholders import STYLE_INSTRUCTIONS_PLACEHOLDER @@ -110,7 +110,7 @@ WORKSPACE_GETTERS: dict[str, Callable[[WorkspaceTemplateContext], str | None]] = USER_GETTERS: dict[str, Callable[[UserTemplateContext], str | None]] = { DISPLAY_NAME: lambda ctx: ctx.display_name, - "email": lambda ctx: ctx.email, + EMAIL: lambda ctx: ctx.email, } SUMMARY_GETTERS: dict[str, Callable[[SummaryTemplateContext], str | None]] = { diff --git a/tests/domain/ai/test_citations.py b/tests/domain/ai/test_citations.py index 659e597..249e9d5 100644 --- a/tests/domain/ai/test_citations.py +++ b/tests/domain/ai/test_citations.py @@ -4,6 +4,9 @@ import pytest from noteflow.domain.ai.citations import SegmentCitation +EXPECTED_DURATION_SECONDS = 15.0 +FROZEN_ASSIGNMENT_MESSAGE = "cannot assign to field" + class TestSegmentCitation: def test_creation_with_valid_values(self) -> None: @@ -17,14 +20,14 @@ class TestSegmentCitation: score=0.95, ) - assert citation.meeting_id == meeting_id - assert citation.segment_id == 1 - assert citation.start_time == 0.0 - assert citation.end_time == 5.0 - assert citation.text == "Test segment text" - assert citation.score == 0.95 + assert citation.meeting_id == meeting_id, "Meeting ID should be preserved" + assert citation.segment_id == 1, "Segment ID should be preserved" + assert citation.start_time == 0.0, "Start time should be preserved" + assert citation.end_time == 5.0, "End time should be preserved" + assert citation.text == "Test segment text", "Text should be preserved" + assert citation.score == 0.95, "Score should be preserved" - def test_duration_property(self) -> None: + def test_duration_property_returns_delta(self) -> None: citation = SegmentCitation( meeting_id=uuid4(), segment_id=1, @@ -33,7 +36,9 @@ class TestSegmentCitation: text="Test", ) - assert citation.duration == 15.0 + assert citation.duration == EXPECTED_DURATION_SECONDS, ( + "Duration should equal end_time - start_time" + ) def test_default_score_is_zero(self) -> None: citation = SegmentCitation( @@ -44,7 +49,7 @@ class TestSegmentCitation: text="Test", ) - assert citation.score == 0.0 + assert citation.score == 0.0, "Default score should be zero" def test_rejects_negative_segment_id(self) -> None: with pytest.raises(ValueError, match="segment_id must be non-negative"): @@ -103,9 +108,9 @@ class TestSegmentCitation: text="Instant moment", ) - assert citation.duration == 0.0 + assert citation.duration == 0.0, "Zero-length segments should have zero duration" - def test_is_frozen(self) -> None: + def test_citation_is_frozen(self) -> None: citation = SegmentCitation( meeting_id=uuid4(), segment_id=1, @@ -114,5 +119,5 @@ class TestSegmentCitation: text="Test", ) - with pytest.raises(AttributeError): - citation.text = "Modified" # type: ignore[misc] + with pytest.raises(AttributeError, match=FROZEN_ASSIGNMENT_MESSAGE): + setattr(citation, "text", "Modified") diff --git a/tests/infrastructure/ai/test_retrieval.py b/tests/infrastructure/ai/test_retrieval.py index 6cbc881..8dd3007 100644 --- a/tests/infrastructure/ai/test_retrieval.py +++ b/tests/infrastructure/ai/test_retrieval.py @@ -1,12 +1,17 @@ -from collections.abc import Sequence +from collections.abc import Awaitable, Callable, Sequence from dataclasses import dataclass +from typing import cast from unittest.mock import AsyncMock from uuid import uuid4 import pytest +from noteflow.domain.value_objects import MeetingId from noteflow.infrastructure.ai.tools.retrieval import ( BatchEmbedderProtocol, + MeetingBatchRetrievalRequest, + MeetingRetrievalDependencies, + MeetingRetrievalRequest, RetrievalResult, retrieve_segments, retrieve_segments_batch, @@ -34,14 +39,24 @@ class TestRetrieveSegments: return AsyncMock() @pytest.fixture - def sample_meeting_id(self) -> object: - return uuid4() + def sample_meeting_id(self) -> MeetingId: + return MeetingId(uuid4()) - async def test_retrieve_segments_success( + @pytest.fixture + def deps( self, mock_embedder: AsyncMock, mock_segment_repo: AsyncMock, - sample_meeting_id: object, + ) -> MeetingRetrievalDependencies: + return MeetingRetrievalDependencies( + embedder=mock_embedder, + segment_repo=mock_segment_repo, + ) + + async def test_retrieve_segments_success( + self, + deps: MeetingRetrievalDependencies, + sample_meeting_id: MeetingId, ) -> None: segment = MockSegment( segment_id=1, @@ -50,52 +65,50 @@ class TestRetrieveSegments: start_time=0.0, end_time=5.0, ) - mock_segment_repo.search_semantic.return_value = [(segment, 0.95)] + deps.segment_repo.search_semantic.return_value = [(segment, 0.95)] results = await retrieve_segments( - query="test query", - embedder=mock_embedder, - segment_repo=mock_segment_repo, - meeting_id=sample_meeting_id, # type: ignore[arg-type] - top_k=5, + MeetingRetrievalRequest( + query="test query", + meeting_id=sample_meeting_id, + top_k=5, + ), + deps, ) - assert len(results) == 1 - assert results[0].segment_id == 1 - assert results[0].text == "Test segment" - assert results[0].score == 0.95 + assert len(results) == 1, "Expected one retrieval result" + assert results[0].segment_id == 1, "Segment ID should match input" + assert results[0].text == "Test segment", "Segment text should match input" + assert results[0].score == 0.95, "Score should preserve search score" async def test_retrieve_segments_calls_embedder_with_query( self, - mock_embedder: AsyncMock, - mock_segment_repo: AsyncMock, + deps: MeetingRetrievalDependencies, ) -> None: - mock_segment_repo.search_semantic.return_value = [] + deps.segment_repo.search_semantic.return_value = [] await retrieve_segments( - query="what happened in the meeting", - embedder=mock_embedder, - segment_repo=mock_segment_repo, + MeetingRetrievalRequest(query="what happened in the meeting"), + deps, ) - mock_embedder.embed.assert_called_once_with("what happened in the meeting") + embed_call = cast(AsyncMock, deps.embedder.embed) + embed_call.assert_called_once_with("what happened in the meeting") async def test_retrieve_segments_passes_embedding_to_repo( self, - mock_embedder: AsyncMock, - mock_segment_repo: AsyncMock, + deps: MeetingRetrievalDependencies, ) -> None: - mock_embedder.embed.return_value = [1.0, 2.0, 3.0] - mock_segment_repo.search_semantic.return_value = [] + deps.embedder.embed.return_value = [1.0, 2.0, 3.0] + deps.segment_repo.search_semantic.return_value = [] await retrieve_segments( - query="test", - embedder=mock_embedder, - segment_repo=mock_segment_repo, - top_k=10, + MeetingRetrievalRequest(query="test", top_k=10), + deps, ) - mock_segment_repo.search_semantic.assert_called_once_with( + search_call = cast(AsyncMock, deps.segment_repo.search_semantic) + search_call.assert_called_once_with( query_embedding=[1.0, 2.0, 3.0], meeting_id=None, limit=10, @@ -103,18 +116,16 @@ class TestRetrieveSegments: async def test_retrieve_segments_empty_result( self, - mock_embedder: AsyncMock, - mock_segment_repo: AsyncMock, + deps: MeetingRetrievalDependencies, ) -> None: - mock_segment_repo.search_semantic.return_value = [] + deps.segment_repo.search_semantic.return_value = [] results = await retrieve_segments( - query="test", - embedder=mock_embedder, - segment_repo=mock_segment_repo, + MeetingRetrievalRequest(query="test"), + deps, ) - assert results == [] + assert results == [], "Expected no results for empty search response" async def test_retrieval_result_is_frozen(self) -> None: result = RetrievalResult( @@ -126,8 +137,8 @@ class TestRetrieveSegments: score=0.9, ) - with pytest.raises(AttributeError): - result.text = "Modified" # type: ignore[misc] + with pytest.raises(AttributeError, match="cannot assign to field"): + setattr(result, "text", "Modified") class MockBatchEmbedder: @@ -145,6 +156,24 @@ class MockBatchEmbedder: return [self._embedding for _ in texts] +def _ordered_search_side_effect( + first: MockSegment, + second: MockSegment, +) -> Callable[[list[float], int, object], Awaitable[list[tuple[MockSegment, float]]]]: + call_count = 0 + + async def side_effect( + query_embedding: list[float], + limit: int, + meeting_id: object, + ) -> list[tuple[MockSegment, float]]: + nonlocal call_count + call_count += 1 + return [(first, 0.9)] if call_count == 1 else [(second, 0.8)] + + return side_effect + + class TestRetrieveSegmentsBatch: @pytest.fixture def mock_embedder(self) -> AsyncMock: @@ -161,8 +190,8 @@ class TestRetrieveSegmentsBatch: return AsyncMock() @pytest.fixture - def sample_meeting_id(self) -> object: - return uuid4() + def sample_meeting_id(self) -> MeetingId: + return MeetingId(uuid4()) async def test_batch_returns_empty_for_no_queries( self, @@ -170,19 +199,21 @@ class TestRetrieveSegmentsBatch: mock_segment_repo: AsyncMock, ) -> None: results = await retrieve_segments_batch( - queries=[], - embedder=mock_embedder, - segment_repo=mock_segment_repo, + MeetingBatchRetrievalRequest(queries=[]), + MeetingRetrievalDependencies( + embedder=mock_embedder, + segment_repo=mock_segment_repo, + ), ) - assert results == [] + assert results == [], "Expected empty results for empty query list" mock_embedder.embed.assert_not_called() async def test_batch_uses_embed_batch_when_available( self, batch_embedder: MockBatchEmbedder, mock_segment_repo: AsyncMock, - sample_meeting_id: object, + sample_meeting_id: MeetingId, ) -> None: segment = MockSegment( segment_id=1, @@ -196,22 +227,28 @@ class TestRetrieveSegmentsBatch: assert isinstance(batch_embedder, BatchEmbedderProtocol) results = await retrieve_segments_batch( - queries=["query1", "query2"], - embedder=batch_embedder, - segment_repo=mock_segment_repo, - meeting_id=sample_meeting_id, # type: ignore[arg-type] + MeetingBatchRetrievalRequest( + queries=["query1", "query2"], + meeting_id=sample_meeting_id, + ), + MeetingRetrievalDependencies( + embedder=batch_embedder, + segment_repo=mock_segment_repo, + ), ) - assert len(results) == 2 - assert len(batch_embedder.embed_batch_calls) == 1 - assert list(batch_embedder.embed_batch_calls[0]) == ["query1", "query2"] - assert batch_embedder.embed_calls == [] + assert len(results) == 2, "Expected one result list per query" + assert len(batch_embedder.embed_batch_calls) == 1, "Expected batch embedding call" + assert list(batch_embedder.embed_batch_calls[0]) == ["query1", "query2"], ( + "Batch embedder should receive queries in order" + ) + assert batch_embedder.embed_calls == [], "Single embed should not be used" async def test_batch_falls_back_to_parallel_embed( self, mock_embedder: AsyncMock, mock_segment_repo: AsyncMock, - sample_meeting_id: object, + sample_meeting_id: MeetingId, ) -> None: segment = MockSegment( segment_id=1, @@ -223,46 +260,43 @@ class TestRetrieveSegmentsBatch: mock_segment_repo.search_semantic.return_value = [(segment, 0.9)] results = await retrieve_segments_batch( - queries=["query1", "query2"], - embedder=mock_embedder, - segment_repo=mock_segment_repo, - meeting_id=sample_meeting_id, # type: ignore[arg-type] + MeetingBatchRetrievalRequest( + queries=["query1", "query2"], + meeting_id=sample_meeting_id, + ), + MeetingRetrievalDependencies( + embedder=mock_embedder, + segment_repo=mock_segment_repo, + ), ) - assert len(results) == 2 - assert mock_embedder.embed.call_count == 2 + assert len(results) == 2, "Expected one result list per query" + assert mock_embedder.embed.call_count == 2, "Expected parallel embed fallback" async def test_batch_preserves_query_order( self, mock_segment_repo: AsyncMock, - sample_meeting_id: object, + sample_meeting_id: MeetingId, ) -> None: segment1 = MockSegment(1, sample_meeting_id, "First", 0.0, 5.0) segment2 = MockSegment(2, sample_meeting_id, "Second", 5.0, 10.0) - - call_count = 0 - - async def side_effect( - query_embedding: list[float], - limit: int, - meeting_id: object, - ) -> list[tuple[MockSegment, float]]: - nonlocal call_count - call_count += 1 - if call_count == 1: - return [(segment1, 0.9)] - return [(segment2, 0.8)] - - mock_segment_repo.search_semantic.side_effect = side_effect + mock_segment_repo.search_semantic.side_effect = _ordered_search_side_effect( + segment1, + segment2, + ) embedder = MockBatchEmbedder([0.1, 0.2]) results = await retrieve_segments_batch( - queries=["first", "second"], - embedder=embedder, - segment_repo=mock_segment_repo, - meeting_id=sample_meeting_id, # type: ignore[arg-type] + MeetingBatchRetrievalRequest( + queries=["first", "second"], + meeting_id=sample_meeting_id, + ), + MeetingRetrievalDependencies( + embedder=embedder, + segment_repo=mock_segment_repo, + ), ) - assert len(results) == 2 - assert results[0][0].text == "First" - assert results[1][0].text == "Second" + assert len(results) == 2, "Expected results for two queries" + assert results[0][0].text == "First", "First query should map to first result" + assert results[1][0].text == "Second", "Second query should map to second result" diff --git a/tests/infrastructure/ai/test_synthesis.py b/tests/infrastructure/ai/test_synthesis.py index 56e20ee..ebc24ff 100644 --- a/tests/infrastructure/ai/test_synthesis.py +++ b/tests/infrastructure/ai/test_synthesis.py @@ -11,6 +11,10 @@ from noteflow.infrastructure.ai.tools.synthesis import ( ) +INVALID_CITATION_ID = 99 +FROZEN_ASSIGNMENT_MESSAGE = "cannot assign to field" + + class TestSynthesizeAnswer: @pytest.fixture def mock_llm(self) -> AsyncMock: @@ -51,8 +55,8 @@ class TestSynthesizeAnswer: llm=mock_llm, ) - assert isinstance(result, SynthesisResult) - assert "deadline" in result.answer.lower() + assert isinstance(result, SynthesisResult), "Result should be a SynthesisResult" + assert "deadline" in result.answer.lower(), "Answer should mention the deadline" async def test_synthesize_answer_extracts_citations( self, @@ -67,14 +71,14 @@ class TestSynthesizeAnswer: llm=mock_llm, ) - assert result.cited_segment_ids == [1, 3] + assert result.cited_segment_ids == [1, 3], "Citations should match referenced segments" async def test_synthesize_answer_filters_invalid_citations( self, mock_llm: AsyncMock, sample_segments: list[RetrievalResult], ) -> None: - mock_llm.complete.return_value = "Found [1], [99], and [3]." + mock_llm.complete.return_value = f"Found [1], [{INVALID_CITATION_ID}], and [3]." result = await synthesize_answer( question="What happened?", @@ -82,8 +86,10 @@ class TestSynthesizeAnswer: llm=mock_llm, ) - assert 99 not in result.cited_segment_ids - assert result.cited_segment_ids == [1, 3] + assert INVALID_CITATION_ID not in result.cited_segment_ids, ( + "Invalid citations should be filtered out" + ) + assert result.cited_segment_ids == [1, 3], "Valid citations should be preserved" async def test_synthesize_answer_builds_prompt_with_segments( self, @@ -100,42 +106,45 @@ class TestSynthesizeAnswer: call_args = mock_llm.complete.call_args prompt = call_args[0][0] - assert "What is happening?" in prompt - assert "[1]" in prompt - assert "[3]" in prompt - assert "John discussed" in prompt + assert "What is happening?" in prompt, "Prompt should include the question" + assert "[1]" in prompt, "Prompt should include citation marker for segment 1" + assert "[3]" in prompt, "Prompt should include citation marker for segment 3" + assert "John discussed" in prompt, "Prompt should include segment text" class TestExtractCitedIds: def test_extracts_single_citation(self) -> None: result = extract_cited_ids("The answer is here [5].", {1, 3, 5}) - assert result == [5] + assert result == [5], "Single citation should be extracted" def test_extracts_multiple_citations(self) -> None: result = extract_cited_ids("See [1] and [3] for details.", {1, 3, 5}) - assert result == [1, 3] + assert result == [1, 3], "Multiple citations should be extracted" def test_filters_invalid_ids(self) -> None: - result = extract_cited_ids("See [1] and [99].", {1, 3, 5}) + result = extract_cited_ids( + f"See [1] and [{INVALID_CITATION_ID}].", + {1, 3, 5}, + ) - assert result == [1] + assert result == [1], "Invalid IDs should be filtered out" def test_deduplicates_citations(self) -> None: result = extract_cited_ids("See [1] and then [1] again.", {1, 3}) - assert result == [1] + assert result == [1], "Duplicates should be removed while preserving order" def test_preserves_order(self) -> None: result = extract_cited_ids("[3] comes first, then [1].", {1, 3}) - assert result == [3, 1] + assert result == [3, 1], "Order should be preserved" def test_empty_for_no_citations(self) -> None: result = extract_cited_ids("No citations here.", {1, 3}) - assert result == [] + assert result == [], "No citations should return an empty list" class TestSynthesisResult: @@ -145,5 +154,5 @@ class TestSynthesisResult: cited_segment_ids=[1, 2], ) - with pytest.raises(AttributeError): - result.answer = "Modified" # type: ignore[misc] + with pytest.raises(AttributeError, match=FROZEN_ASSIGNMENT_MESSAGE): + setattr(result, "answer", "Modified") diff --git a/tests/infrastructure/test_converters.py b/tests/infrastructure/test_converters.py index c5c85d1..9fe3d32 100644 --- a/tests/infrastructure/test_converters.py +++ b/tests/infrastructure/test_converters.py @@ -149,8 +149,8 @@ class TestOrmConverterToOrmKwargs: assert result["word_index"] == 5, "Word index preserved" -class TestNerConverterToOrmKwargs: - """Tests for NerConverter.to_orm_kwargs.""" +class TestNerConverterToEntityOrmKwargs: + """Tests for NerConverter.to_entity_orm_kwargs.""" def test_ner_entity_to_orm_kwargs(self) -> None: """Convert domain NamedEntity to ORM kwargs dict.""" @@ -166,7 +166,7 @@ class TestNerConverterToOrmKwargs: is_pinned=True, ) - result = NerConverter.to_orm_kwargs(entity) + result = NerConverter.to_entity_orm_kwargs(entity) assert result["id"] == entity.id, "ID should be preserved" assert result["meeting_id"] == meeting_id, "Meeting ID should be preserved" @@ -185,7 +185,7 @@ class TestNerConverterToOrmKwargs: confidence=0.8, ) - result = NerConverter.to_orm_kwargs(entity) + result = NerConverter.to_entity_orm_kwargs(entity) assert result["category"] == "person", "category value should be 'person'" assert isinstance(result["category"], str), "category should be string type" @@ -206,7 +206,7 @@ class TestNerConverterToOrmKwargs: ) -> None: """All category enum values convert to correct string.""" entity = NamedEntity(text="Test", category=category, confidence=0.5) - result = NerConverter.to_orm_kwargs(entity) + result = NerConverter.to_entity_orm_kwargs(entity) assert result["category"] == expected_value, f"category {category} should convert to '{expected_value}'" @@ -287,7 +287,7 @@ class TestNerConverterRoundTrip: self, round_trip_entity: NamedEntity ) -> None: """Round-trip conversion preserves all field values.""" - orm_kwargs = NerConverter.to_orm_kwargs(round_trip_entity) + orm_kwargs = NerConverter.to_entity_orm_kwargs(round_trip_entity) mock_orm = _create_mock_ner_orm_from_kwargs(orm_kwargs) result = NerConverter.orm_to_domain(mock_orm) diff --git a/tests/infrastructure/test_integration_converters.py b/tests/infrastructure/test_integration_converters.py index 3b53c94..05f0929 100644 --- a/tests/infrastructure/test_integration_converters.py +++ b/tests/infrastructure/test_integration_converters.py @@ -177,7 +177,7 @@ class TestIntegrationConverterOrmToDomain: class TestIntegrationConverterToOrmKwargs: - """Tests for IntegrationConverter.to_orm_kwargs.""" + """Tests for IntegrationConverter.to_integration_orm_kwargs.""" @pytest.fixture def integration_entity(self) -> Integration: @@ -199,7 +199,7 @@ class TestIntegrationConverterToOrmKwargs: self, integration_entity: Integration ) -> None: """Convert domain Integration to ORM kwargs dict.""" - result = IntegrationConverter.to_orm_kwargs(integration_entity) + result = IntegrationConverter.to_integration_orm_kwargs(integration_entity) assert result["id"] == integration_entity.id, "ID should be preserved" assert result["workspace_id"] == integration_entity.workspace_id, "Workspace ID should be preserved" @@ -230,7 +230,7 @@ class TestIntegrationConverterToOrmKwargs: type=type_enum, status=IntegrationStatus.DISCONNECTED, ) - result = IntegrationConverter.to_orm_kwargs(integration) + result = IntegrationConverter.to_integration_orm_kwargs(integration) assert result["type"] == expected_string, f"Type enum {type_enum} should convert to '{expected_string}'" @pytest.mark.parametrize( @@ -252,7 +252,7 @@ class TestIntegrationConverterToOrmKwargs: type=IntegrationType.CALENDAR, status=status_enum, ) - result = IntegrationConverter.to_orm_kwargs(integration) + result = IntegrationConverter.to_integration_orm_kwargs(integration) assert result["status"] == expected_string, f"Status enum {status_enum} should convert to '{expected_string}'" @@ -330,7 +330,7 @@ class TestSyncRunConverterOrmToDomain: class TestSyncRunConverterToOrmKwargs: - """Tests for SyncRunConverter.to_orm_kwargs.""" + """Tests for SyncRunConverter.to_sync_run_orm_kwargs.""" def test_sync_run_to_orm_kwargs(self) -> None: """Convert domain SyncRun to ORM kwargs dict.""" @@ -345,7 +345,7 @@ class TestSyncRunConverterToOrmKwargs: stats={"items_synced": SYNC_RUN_ITEMS_COMPLETE}, ) - result = SyncRunConverter.to_orm_kwargs(sync_run) + result = SyncRunConverter.to_sync_run_orm_kwargs(sync_run) assert result["id"] == sync_run.id, "ID should be preserved" assert result["integration_id"] == sync_run.integration_id, "Integration ID should be preserved" @@ -371,7 +371,7 @@ class TestSyncRunConverterToOrmKwargs: status=status_enum, started_at=datetime(2024, 1, 15, 12, 0, 0, tzinfo=UTC), ) - result = SyncRunConverter.to_orm_kwargs(sync_run) + result = SyncRunConverter.to_sync_run_orm_kwargs(sync_run) assert result["status"] == expected_string, f"Status enum {status_enum} should convert to '{expected_string}'" @@ -412,7 +412,7 @@ class TestIntegrationConverterRoundTrip: self, round_trip_integration: Integration ) -> None: """Round-trip conversion preserves all Integration field values.""" - orm_kwargs = IntegrationConverter.to_orm_kwargs(round_trip_integration) + orm_kwargs = IntegrationConverter.to_integration_orm_kwargs(round_trip_integration) mock_orm = _create_mock_integration_orm_from_kwargs(orm_kwargs) result = IntegrationConverter.orm_to_domain(mock_orm) @@ -428,7 +428,7 @@ class TestIntegrationConverterRoundTrip: self, round_trip_sync_run: SyncRun ) -> None: """Round-trip conversion preserves all SyncRun field values.""" - orm_kwargs = SyncRunConverter.to_orm_kwargs(round_trip_sync_run) + orm_kwargs = SyncRunConverter.to_sync_run_orm_kwargs(round_trip_sync_run) mock_orm = _create_mock_sync_run_orm_from_kwargs(orm_kwargs) result = SyncRunConverter.orm_to_domain(mock_orm) diff --git a/uv.lock b/uv.lock index 05d661a..2ea9a39 100644 --- a/uv.lock +++ b/uv.lock @@ -1678,6 +1678,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, ] +[[package]] +name = "jsonpatch" +version = "1.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" }, +] + +[[package]] +name = "jsonpath-ng" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ply" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/86/08646239a313f895186ff0a4573452038eed8c86f54380b3ebac34d32fb2/jsonpath-ng-1.7.0.tar.gz", hash = "sha256:f6f5f7fd4e5ff79c785f1573b394043b39849fb2bb47bcead935d12b00beab3c", size = 37838, upload-time = "2024-10-11T15:41:42.404Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/5a/73ecb3d82f8615f32ccdadeb9356726d6cae3a4bbc840b437ceb95708063/jsonpath_ng-1.7.0-py3-none-any.whl", hash = "sha256:f3d7f9e848cba1b6da28c55b1c26ff915dc9e0b1ba7e752a53d6da8d5cbd00b6", size = 30105, upload-time = "2024-11-20T17:58:30.418Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114, upload-time = "2024-06-10T19:24:42.462Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" }, +] + [[package]] name = "julius" version = "0.2.7" @@ -1776,6 +1809,130 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260, upload-time = "2025-08-10T21:27:36.606Z" }, ] +[[package]] +name = "langchain-core" +version = "1.2.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpatch" }, + { name = "langsmith" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/0e/664d8d81b3493e09cbab72448d2f9d693d1fa5aa2bcc488602203a9b6da0/langchain_core-1.2.7.tar.gz", hash = "sha256:e1460639f96c352b4a41c375f25aeb8d16ffc1769499fb1c20503aad59305ced", size = 837039, upload-time = "2026-01-09T17:44:25.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/6f/34a9fba14d191a67f7e2ee3dbce3e9b86d2fa7310e2c7f2c713583481bd2/langchain_core-1.2.7-py3-none-any.whl", hash = "sha256:452f4fef7a3d883357b22600788d37e3d8854ef29da345b7ac7099f33c31828b", size = 490232, upload-time = "2026-01-09T17:44:24.236Z" }, +] + +[[package]] +name = "langgraph" +version = "1.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, + { name = "langgraph-prebuilt" }, + { name = "langgraph-sdk" }, + { name = "pydantic" }, + { name = "xxhash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/9c/dac99ab1732e9fb2d3b673482ac28f02bee222c0319a3b8f8f73d90727e6/langgraph-1.0.6.tar.gz", hash = "sha256:dd8e754c76d34a07485308d7117221acf63990e7de8f46ddf5fe256b0a22e6c5", size = 495092, upload-time = "2026-01-12T20:33:30.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/45/9960747781416bed4e531ed0c6b2f2c739bc7b5397d8e92155463735a40e/langgraph-1.0.6-py3-none-any.whl", hash = "sha256:bcfce190974519c72e29f6e5b17f0023914fd6f936bfab8894083215b271eb89", size = 157356, upload-time = "2026-01-12T20:33:29.191Z" }, +] + +[[package]] +name = "langgraph-checkpoint" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "ormsgpack" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/07/2b1c042fa87d40cf2db5ca27dc4e8dd86f9a0436a10aa4361a8982718ae7/langgraph_checkpoint-3.0.1.tar.gz", hash = "sha256:59222f875f85186a22c494aedc65c4e985a3df27e696e5016ba0b98a5ed2cee0", size = 137785, upload-time = "2025-11-04T21:55:47.774Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/e3/616e3a7ff737d98c1bbb5700dd62278914e2a9ded09a79a1fa93cf24ce12/langgraph_checkpoint-3.0.1-py3-none-any.whl", hash = "sha256:9b04a8d0edc0474ce4eaf30c5d731cee38f11ddff50a6177eead95b5c4e4220b", size = 46249, upload-time = "2025-11-04T21:55:46.472Z" }, +] + +[[package]] +name = "langgraph-checkpoint-postgres" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langgraph-checkpoint" }, + { name = "orjson" }, + { name = "psycopg" }, + { name = "psycopg-pool" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/86/5f3c01346be4b7f455aeeff915f78c878fe9eee5cc8b3782df0886b409bf/langgraph_checkpoint_postgres-3.0.3.tar.gz", hash = "sha256:f77852340198b9e71f2d52da5b591cfc55a4a2e537001868a83b9ab1865f9146", size = 127198, upload-time = "2026-01-12T20:36:40.205Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/f8/5a6f9dbc3a93e3b9a567ba0a8b6df5e89882be64dc1c6586a33ec15e93b8/langgraph_checkpoint_postgres-3.0.3-py3-none-any.whl", hash = "sha256:f603f4f81961e2740bd70679affeb540452d467990d4692132c07c20870bdbb1", size = 42715, upload-time = "2026-01-12T20:36:38.636Z" }, +] + +[[package]] +name = "langgraph-checkpoint-redis" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langgraph-checkpoint" }, + { name = "orjson" }, + { name = "redis" }, + { name = "redisvl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/f3/16ff43bcfa3efa85068c7f61e34578647c928a61a130cf26d8c408b66077/langgraph_checkpoint_redis-0.3.2.tar.gz", hash = "sha256:1fa28a3f06c1152ebcbfdddec865cdcac65e92ecccd39ecbca99a8be18a8d71b", size = 87565, upload-time = "2026-01-04T16:02:32.355Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/5d/d7d6b4775443c595c2a40a2f01c72793575e800b302349fe35ba466ae77d/langgraph_checkpoint_redis-0.3.2-py3-none-any.whl", hash = "sha256:7641bf5d3f1b64b396e064856f6fd36e983548e353fe2a11c9b0357bceb304fd", size = 92811, upload-time = "2026-01-04T16:02:30.789Z" }, +] + +[[package]] +name = "langgraph-prebuilt" +version = "1.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/f5/8c75dace0d729561dce2966e630c5e312193df7e5df41a7e10cd7378c3a7/langgraph_prebuilt-1.0.6.tar.gz", hash = "sha256:c5f6cf0f5a0ac47643d2e26ae6faa38cb28885ecde67911190df9e30c4f72361", size = 162623, upload-time = "2026-01-12T20:31:28.425Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/6c/4045822b0630cfc0f8624c4499ceaf90644142143c063a8dc385a7424fc3/langgraph_prebuilt-1.0.6-py3-none-any.whl", hash = "sha256:9fdc35048ff4ac985a55bd2a019a86d45b8184551504aff6780d096c678b39ae", size = 35322, upload-time = "2026-01-12T20:31:27.161Z" }, +] + +[[package]] +name = "langgraph-sdk" +version = "0.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "orjson" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/0f/ed0634c222eed48a31ba48eab6881f94ad690d65e44fe7ca838240a260c1/langgraph_sdk-0.3.3.tar.gz", hash = "sha256:c34c3dce3b6848755eb61f0c94369d1ba04aceeb1b76015db1ea7362c544fb26", size = 130589, upload-time = "2026-01-13T00:30:43.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/be/4ad511bacfdd854afb12974f407cb30010dceb982dc20c55491867b34526/langgraph_sdk-0.3.3-py3-none-any.whl", hash = "sha256:a52ebaf09d91143e55378bb2d0b033ed98f57f48c9ad35c8f81493b88705fc7b", size = 67021, upload-time = "2026-01-13T00:30:42.264Z" }, +] + +[[package]] +name = "langsmith" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "uuid-utils" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/85/9c7933052a997da1b85bc5c774f3865e9b1da1c8d71541ea133178b13229/langsmith-0.6.4.tar.gz", hash = "sha256:36f7223a01c218079fbb17da5e536ebbaf5c1468c028abe070aa3ae59bc99ec8", size = 919964, upload-time = "2026-01-15T20:02:28.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/0f/09a6637a7ba777eb307b7c80852d9ee26438e2bdafbad6fcc849ff9d9192/langsmith-0.6.4-py3-none-any.whl", hash = "sha256:ac4835860160be371042c7adbba3cb267bcf8d96a5ea976c33a8a4acad6c5486", size = 283503, upload-time = "2026-01-15T20:02:26.662Z" }, +] + [[package]] name = "librt" version = "0.7.4" @@ -2032,6 +2189,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "ml-dtypes" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/15/76f86faa0902836cc133939732f7611ace68cf54148487a99c539c272dc8/ml_dtypes-0.4.1.tar.gz", hash = "sha256:fad5f2de464fd09127e49b7fd1252b9006fb43d2edc1ff112d390c324af5ca7a", size = 692594, upload-time = "2024-09-13T19:07:11.624Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/1a/99e924f12e4b62139fbac87419698c65f956d58de0dbfa7c028fa5b096aa/ml_dtypes-0.4.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:827d3ca2097085cf0355f8fdf092b888890bb1b1455f52801a2d7756f056f54b", size = 405077, upload-time = "2024-09-13T19:06:57.538Z" }, + { url = "https://files.pythonhosted.org/packages/8f/8c/7b610bd500617854c8cc6ed7c8cfb9d48d6a5c21a1437a36a4b9bc8a3598/ml_dtypes-0.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:772426b08a6172a891274d581ce58ea2789cc8abc1c002a27223f314aaf894e7", size = 2181554, upload-time = "2024-09-13T19:06:59.196Z" }, + { url = "https://files.pythonhosted.org/packages/c7/c6/f89620cecc0581dc1839e218c4315171312e46c62a62da6ace204bda91c0/ml_dtypes-0.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:126e7d679b8676d1a958f2651949fbfa182832c3cd08020d8facd94e4114f3e9", size = 2160488, upload-time = "2024-09-13T19:07:03.131Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/a742d3c31b2cc8557a48efdde53427fd5f9caa2fa3c9c27d826e78a66f51/ml_dtypes-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:df0fb650d5c582a9e72bb5bd96cfebb2cdb889d89daff621c8fbc60295eba66c", size = 127462, upload-time = "2024-09-13T19:07:04.916Z" }, +] + [[package]] name = "more-itertools" version = "10.8.0" @@ -2278,10 +2450,14 @@ dependencies = [ { name = "grpcio-tools" }, { name = "httpx" }, { name = "keyring" }, + { name = "langgraph" }, + { name = "langgraph-checkpoint-postgres" }, + { name = "langgraph-checkpoint-redis" }, { name = "openai-whisper" }, { name = "pgvector" }, { name = "protobuf" }, { name = "psutil" }, + { name = "psycopg" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "rich" }, @@ -2444,6 +2620,9 @@ requires-dist = [ { name = "grpcio-tools", specifier = ">=1.60" }, { name = "httpx", specifier = ">=0.27" }, { name = "keyring", specifier = ">=25.0" }, + { name = "langgraph", specifier = ">=1.0.6" }, + { name = "langgraph-checkpoint-postgres", specifier = ">=3.0.3" }, + { name = "langgraph-checkpoint-redis", specifier = ">=0.3.2" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8" }, { name = "noteflow", extras = ["audio", "dev", "triggers", "summarization", "diarization", "pdf", "ner", "ner-gliner", "calendar", "observability"], marker = "extra == 'all'" }, { name = "numpy", marker = "extra == 'audio'", specifier = ">=1.26" }, @@ -2465,6 +2644,7 @@ requires-dist = [ { name = "pgvector", specifier = ">=0.3" }, { name = "protobuf", specifier = ">=4.25" }, { name = "psutil", specifier = ">=7.1.3" }, + { name = "psycopg", specifier = ">=3.3.2" }, { name = "pyannote-audio", marker = "extra == 'diarization'", specifier = ">=3.3" }, { name = "pyannote-audio", marker = "extra == 'optional'", specifier = ">=3.3" }, { name = "pydantic", specifier = ">=2.0" }, @@ -2943,6 +3123,98 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/de/3d8455b08cb6312f8cc46aacdf16c71d4d881a1db4a4140fc5ef31108422/optuna-4.6.0-py3-none-any.whl", hash = "sha256:4c3a9facdef2b2dd7e3e2a8ae3697effa70fae4056fcf3425cfc6f5a40feb069", size = 404708, upload-time = "2025-11-10T05:14:28.6Z" }, ] +[[package]] +name = "orjson" +version = "3.11.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/b8/333fdb27840f3bf04022d21b654a35f58e15407183aeb16f3b41aa053446/orjson-3.11.5.tar.gz", hash = "sha256:82393ab47b4fe44ffd0a7659fa9cfaacc717eb617c93cde83795f14af5c2e9d5", size = 5972347, upload-time = "2025-12-06T15:55:39.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a4/8052a029029b096a78955eadd68ab594ce2197e24ec50e6b6d2ab3f4e33b/orjson-3.11.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:334e5b4bff9ad101237c2d799d9fd45737752929753bf4faf4b207335a416b7d", size = 245347, upload-time = "2025-12-06T15:54:22.061Z" }, + { url = "https://files.pythonhosted.org/packages/64/67/574a7732bd9d9d79ac620c8790b4cfe0717a3d5a6eb2b539e6e8995e24a0/orjson-3.11.5-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:ff770589960a86eae279f5d8aa536196ebda8273a2a07db2a54e82b93bc86626", size = 129435, upload-time = "2025-12-06T15:54:23.615Z" }, + { url = "https://files.pythonhosted.org/packages/52/8d/544e77d7a29d90cf4d9eecd0ae801c688e7f3d1adfa2ebae5e1e94d38ab9/orjson-3.11.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed24250e55efbcb0b35bed7caaec8cedf858ab2f9f2201f17b8938c618c8ca6f", size = 132074, upload-time = "2025-12-06T15:54:24.694Z" }, + { url = "https://files.pythonhosted.org/packages/6e/57/b9f5b5b6fbff9c26f77e785baf56ae8460ef74acdb3eae4931c25b8f5ba9/orjson-3.11.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a66d7769e98a08a12a139049aac2f0ca3adae989817f8c43337455fbc7669b85", size = 130520, upload-time = "2025-12-06T15:54:26.185Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6d/d34970bf9eb33f9ec7c979a262cad86076814859e54eb9a059a52f6dc13d/orjson-3.11.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86cfc555bfd5794d24c6a1903e558b50644e5e68e6471d66502ce5cb5fdef3f9", size = 136209, upload-time = "2025-12-06T15:54:27.264Z" }, + { url = "https://files.pythonhosted.org/packages/e7/39/bc373b63cc0e117a105ea12e57280f83ae52fdee426890d57412432d63b3/orjson-3.11.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a230065027bc2a025e944f9d4714976a81e7ecfa940923283bca7bbc1f10f626", size = 139837, upload-time = "2025-12-06T15:54:28.75Z" }, + { url = "https://files.pythonhosted.org/packages/cb/aa/7c4818c8d7d324da220f4f1af55c343956003aa4d1ce1857bdc1d396ba69/orjson-3.11.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b29d36b60e606df01959c4b982729c8845c69d1963f88686608be9ced96dbfaa", size = 137307, upload-time = "2025-12-06T15:54:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/46/bf/0993b5a056759ba65145effe3a79dd5a939d4a070eaa5da2ee3180fbb13f/orjson-3.11.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c74099c6b230d4261fdc3169d50efc09abf38ace1a42ea2f9994b1d79153d477", size = 139020, upload-time = "2025-12-06T15:54:31.024Z" }, + { url = "https://files.pythonhosted.org/packages/65/e8/83a6c95db3039e504eda60fc388f9faedbb4f6472f5aba7084e06552d9aa/orjson-3.11.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e697d06ad57dd0c7a737771d470eedc18e68dfdefcdd3b7de7f33dfda5b6212e", size = 141099, upload-time = "2025-12-06T15:54:32.196Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b4/24fdc024abfce31c2f6812973b0a693688037ece5dc64b7a60c1ce69e2f2/orjson-3.11.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e08ca8a6c851e95aaecc32bc44a5aa75d0ad26af8cdac7c77e4ed93acf3d5b69", size = 413540, upload-time = "2025-12-06T15:54:33.361Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/01c0ec95d55ed0c11e4cae3e10427e479bba40c77312b63e1f9665e0737d/orjson-3.11.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e8b5f96c05fce7d0218df3fdfeb962d6b8cfff7e3e20264306b46dd8b217c0f3", size = 151530, upload-time = "2025-12-06T15:54:34.6Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d4/f9ebc57182705bb4bbe63f5bbe14af43722a2533135e1d2fb7affa0c355d/orjson-3.11.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ddbfdb5099b3e6ba6d6ea818f61997bb66de14b411357d24c4612cf1ebad08ca", size = 141863, upload-time = "2025-12-06T15:54:35.801Z" }, + { url = "https://files.pythonhosted.org/packages/0d/04/02102b8d19fdcb009d72d622bb5781e8f3fae1646bf3e18c53d1bc8115b5/orjson-3.11.5-cp312-cp312-win32.whl", hash = "sha256:9172578c4eb09dbfcf1657d43198de59b6cef4054de385365060ed50c458ac98", size = 135255, upload-time = "2025-12-06T15:54:37.209Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fb/f05646c43d5450492cb387de5549f6de90a71001682c17882d9f66476af5/orjson-3.11.5-cp312-cp312-win_amd64.whl", hash = "sha256:2b91126e7b470ff2e75746f6f6ee32b9ab67b7a93c8ba1d15d3a0caaf16ec875", size = 133252, upload-time = "2025-12-06T15:54:38.401Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a6/7b8c0b26ba18c793533ac1cd145e131e46fcf43952aa94c109b5b913c1f0/orjson-3.11.5-cp312-cp312-win_arm64.whl", hash = "sha256:acbc5fac7e06777555b0722b8ad5f574739e99ffe99467ed63da98f97f9ca0fe", size = 126777, upload-time = "2025-12-06T15:54:39.515Z" }, + { url = "https://files.pythonhosted.org/packages/10/43/61a77040ce59f1569edf38f0b9faadc90c8cf7e9bec2e0df51d0132c6bb7/orjson-3.11.5-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3b01799262081a4c47c035dd77c1301d40f568f77cc7ec1bb7db5d63b0a01629", size = 245271, upload-time = "2025-12-06T15:54:40.878Z" }, + { url = "https://files.pythonhosted.org/packages/55/f9/0f79be617388227866d50edd2fd320cb8fb94dc1501184bb1620981a0aba/orjson-3.11.5-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:61de247948108484779f57a9f406e4c84d636fa5a59e411e6352484985e8a7c3", size = 129422, upload-time = "2025-12-06T15:54:42.403Z" }, + { url = "https://files.pythonhosted.org/packages/77/42/f1bf1549b432d4a78bfa95735b79b5dac75b65b5bb815bba86ad406ead0a/orjson-3.11.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:894aea2e63d4f24a7f04a1908307c738d0dce992e9249e744b8f4e8dd9197f39", size = 132060, upload-time = "2025-12-06T15:54:43.531Z" }, + { url = "https://files.pythonhosted.org/packages/25/49/825aa6b929f1a6ed244c78acd7b22c1481fd7e5fda047dc8bf4c1a807eb6/orjson-3.11.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ddc21521598dbe369d83d4d40338e23d4101dad21dae0e79fa20465dbace019f", size = 130391, upload-time = "2025-12-06T15:54:45.059Z" }, + { url = "https://files.pythonhosted.org/packages/42/ec/de55391858b49e16e1aa8f0bbbb7e5997b7345d8e984a2dec3746d13065b/orjson-3.11.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7cce16ae2f5fb2c53c3eafdd1706cb7b6530a67cc1c17abe8ec747f5cd7c0c51", size = 135964, upload-time = "2025-12-06T15:54:46.576Z" }, + { url = "https://files.pythonhosted.org/packages/1c/40/820bc63121d2d28818556a2d0a09384a9f0262407cf9fa305e091a8048df/orjson-3.11.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e46c762d9f0e1cfb4ccc8515de7f349abbc95b59cb5a2bd68df5973fdef913f8", size = 139817, upload-time = "2025-12-06T15:54:48.084Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/3a445ca9a84a0d59d26365fd8898ff52bdfcdcb825bcc6519830371d2364/orjson-3.11.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7345c759276b798ccd6d77a87136029e71e66a8bbf2d2755cbdde1d82e78706", size = 137336, upload-time = "2025-12-06T15:54:49.426Z" }, + { url = "https://files.pythonhosted.org/packages/9a/b3/dc0d3771f2e5d1f13368f56b339c6782f955c6a20b50465a91acb79fe961/orjson-3.11.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75bc2e59e6a2ac1dd28901d07115abdebc4563b5b07dd612bf64260a201b1c7f", size = 138993, upload-time = "2025-12-06T15:54:50.939Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a2/65267e959de6abe23444659b6e19c888f242bf7725ff927e2292776f6b89/orjson-3.11.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:54aae9b654554c3b4edd61896b978568c6daa16af96fa4681c9b5babd469f863", size = 141070, upload-time = "2025-12-06T15:54:52.414Z" }, + { url = "https://files.pythonhosted.org/packages/63/c9/da44a321b288727a322c6ab17e1754195708786a04f4f9d2220a5076a649/orjson-3.11.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4bdd8d164a871c4ec773f9de0f6fe8769c2d6727879c37a9666ba4183b7f8228", size = 413505, upload-time = "2025-12-06T15:54:53.67Z" }, + { url = "https://files.pythonhosted.org/packages/7f/17/68dc14fa7000eefb3d4d6d7326a190c99bb65e319f02747ef3ebf2452f12/orjson-3.11.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a261fef929bcf98a60713bf5e95ad067cea16ae345d9a35034e73c3990e927d2", size = 151342, upload-time = "2025-12-06T15:54:55.113Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c5/ccee774b67225bed630a57478529fc026eda33d94fe4c0eac8fe58d4aa52/orjson-3.11.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c028a394c766693c5c9909dec76b24f37e6a1b91999e8d0c0d5feecbe93c3e05", size = 141823, upload-time = "2025-12-06T15:54:56.331Z" }, + { url = "https://files.pythonhosted.org/packages/67/80/5d00e4155d0cd7390ae2087130637671da713959bb558db9bac5e6f6b042/orjson-3.11.5-cp313-cp313-win32.whl", hash = "sha256:2cc79aaad1dfabe1bd2d50ee09814a1253164b3da4c00a78c458d82d04b3bdef", size = 135236, upload-time = "2025-12-06T15:54:57.507Z" }, + { url = "https://files.pythonhosted.org/packages/95/fe/792cc06a84808dbdc20ac6eab6811c53091b42f8e51ecebf14b540e9cfe4/orjson-3.11.5-cp313-cp313-win_amd64.whl", hash = "sha256:ff7877d376add4e16b274e35a3f58b7f37b362abf4aa31863dadacdd20e3a583", size = 133167, upload-time = "2025-12-06T15:54:58.71Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/d158bd8b50e3b1cfdcf406a7e463f6ffe3f0d167b99634717acdaf5e299f/orjson-3.11.5-cp313-cp313-win_arm64.whl", hash = "sha256:59ac72ea775c88b163ba8d21b0177628bd015c5dd060647bbab6e22da3aad287", size = 126712, upload-time = "2025-12-06T15:54:59.892Z" }, + { url = "https://files.pythonhosted.org/packages/c2/60/77d7b839e317ead7bb225d55bb50f7ea75f47afc489c81199befc5435b50/orjson-3.11.5-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e446a8ea0a4c366ceafc7d97067bfd55292969143b57e3c846d87fc701e797a0", size = 245252, upload-time = "2025-12-06T15:55:01.127Z" }, + { url = "https://files.pythonhosted.org/packages/f1/aa/d4639163b400f8044cef0fb9aa51b0337be0da3a27187a20d1166e742370/orjson-3.11.5-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:53deb5addae9c22bbe3739298f5f2196afa881ea75944e7720681c7080909a81", size = 129419, upload-time = "2025-12-06T15:55:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/30/94/9eabf94f2e11c671111139edf5ec410d2f21e6feee717804f7e8872d883f/orjson-3.11.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82cd00d49d6063d2b8791da5d4f9d20539c5951f965e45ccf4e96d33505ce68f", size = 132050, upload-time = "2025-12-06T15:55:03.918Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c8/ca10f5c5322f341ea9a9f1097e140be17a88f88d1cfdd29df522970d9744/orjson-3.11.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3fd15f9fc8c203aeceff4fda211157fad114dde66e92e24097b3647a08f4ee9e", size = 130370, upload-time = "2025-12-06T15:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/25/d4/e96824476d361ee2edd5c6290ceb8d7edf88d81148a6ce172fc00278ca7f/orjson-3.11.5-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9df95000fbe6777bf9820ae82ab7578e8662051bb5f83d71a28992f539d2cda7", size = 136012, upload-time = "2025-12-06T15:55:06.402Z" }, + { url = "https://files.pythonhosted.org/packages/85/8e/9bc3423308c425c588903f2d103cfcfe2539e07a25d6522900645a6f257f/orjson-3.11.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92a8d676748fca47ade5bc3da7430ed7767afe51b2f8100e3cd65e151c0eaceb", size = 139809, upload-time = "2025-12-06T15:55:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/b404e94e0b02a232b957c54643ce68d0268dacb67ac33ffdee24008c8b27/orjson-3.11.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa0f513be38b40234c77975e68805506cad5d57b3dfd8fe3baa7f4f4051e15b4", size = 137332, upload-time = "2025-12-06T15:55:08.961Z" }, + { url = "https://files.pythonhosted.org/packages/51/30/cc2d69d5ce0ad9b84811cdf4a0cd5362ac27205a921da524ff42f26d65e0/orjson-3.11.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa1863e75b92891f553b7922ce4ee10ed06db061e104f2b7815de80cdcb135ad", size = 138983, upload-time = "2025-12-06T15:55:10.595Z" }, + { url = "https://files.pythonhosted.org/packages/0e/87/de3223944a3e297d4707d2fe3b1ffb71437550e165eaf0ca8bbe43ccbcb1/orjson-3.11.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4be86b58e9ea262617b8ca6251a2f0d63cc132a6da4b5fcc8e0a4128782c829", size = 141069, upload-time = "2025-12-06T15:55:11.832Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/81d5087ae74be33bcae3ff2d80f5ccaa4a8fedc6d39bf65a427a95b8977f/orjson-3.11.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:b923c1c13fa02084eb38c9c065afd860a5cff58026813319a06949c3af5732ac", size = 413491, upload-time = "2025-12-06T15:55:13.314Z" }, + { url = "https://files.pythonhosted.org/packages/d0/6f/f6058c21e2fc1efaf918986dbc2da5cd38044f1a2d4b7b91ad17c4acf786/orjson-3.11.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1b6bd351202b2cd987f35a13b5e16471cf4d952b42a73c391cc537974c43ef6d", size = 151375, upload-time = "2025-12-06T15:55:14.715Z" }, + { url = "https://files.pythonhosted.org/packages/54/92/c6921f17d45e110892899a7a563a925b2273d929959ce2ad89e2525b885b/orjson-3.11.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bb150d529637d541e6af06bbe3d02f5498d628b7f98267ff87647584293ab439", size = 141850, upload-time = "2025-12-06T15:55:15.94Z" }, + { url = "https://files.pythonhosted.org/packages/88/86/cdecb0140a05e1a477b81f24739da93b25070ee01ce7f7242f44a6437594/orjson-3.11.5-cp314-cp314-win32.whl", hash = "sha256:9cc1e55c884921434a84a0c3dd2699eb9f92e7b441d7f53f3941079ec6ce7499", size = 135278, upload-time = "2025-12-06T15:55:17.202Z" }, + { url = "https://files.pythonhosted.org/packages/e4/97/b638d69b1e947d24f6109216997e38922d54dcdcdb1b11c18d7efd2d3c59/orjson-3.11.5-cp314-cp314-win_amd64.whl", hash = "sha256:a4f3cb2d874e03bc7767c8f88adaa1a9a05cecea3712649c3b58589ec7317310", size = 133170, upload-time = "2025-12-06T15:55:18.468Z" }, + { url = "https://files.pythonhosted.org/packages/8f/dd/f4fff4a6fe601b4f8f3ba3aa6da8ac33d17d124491a3b804c662a70e1636/orjson-3.11.5-cp314-cp314-win_arm64.whl", hash = "sha256:38b22f476c351f9a1c43e5b07d8b5a02eb24a6ab8e75f700f7d479d4568346a5", size = 126713, upload-time = "2025-12-06T15:55:19.738Z" }, +] + +[[package]] +name = "ormsgpack" +version = "1.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031, upload-time = "2026-01-18T20:55:28.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/36/16c4b1921c308a92cef3bf6663226ae283395aa0ff6e154f925c32e91ff5/ormsgpack-1.12.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7a29d09b64b9694b588ff2f80e9826bdceb3a2b91523c5beae1fab27d5c940e7", size = 378618, upload-time = "2026-01-18T20:55:50.835Z" }, + { url = "https://files.pythonhosted.org/packages/c0/68/468de634079615abf66ed13bb5c34ff71da237213f29294363beeeca5306/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b39e629fd2e1c5b2f46f99778450b59454d1f901bc507963168985e79f09c5d", size = 203186, upload-time = "2026-01-18T20:56:11.163Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/d756e01961442688b7939bacd87ce13bfad7d26ce24f910f6028178b2cc8/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:958dcb270d30a7cb633a45ee62b9444433fa571a752d2ca484efdac07480876e", size = 210738, upload-time = "2026-01-18T20:56:09.181Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ba/795b1036888542c9113269a3f5690ab53dd2258c6fb17676ac4bd44fcf94/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d379d72b6c5e964851c77cfedfb386e474adee4fd39791c2c5d9efb53505cc", size = 212569, upload-time = "2026-01-18T20:56:06.135Z" }, + { url = "https://files.pythonhosted.org/packages/6c/aa/bff73c57497b9e0cba8837c7e4bcab584b1a6dbc91a5dd5526784a5030c8/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8463a3fc5f09832e67bdb0e2fda6d518dc4281b133166146a67f54c08496442e", size = 387166, upload-time = "2026-01-18T20:55:36.738Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cf/f8283cba44bcb7b14f97b6274d449db276b3a86589bdb363169b51bc12de/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eddffb77eff0bad4e67547d67a130604e7e2dfbb7b0cde0796045be4090f35c6", size = 482498, upload-time = "2026-01-18T20:55:29.626Z" }, + { url = "https://files.pythonhosted.org/packages/05/be/71e37b852d723dfcbe952ad04178c030df60d6b78eba26bfd14c9a40575e/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcd55e5f6ba0dbce624942adf9f152062135f991a0126064889f68eb850de0dd", size = 425518, upload-time = "2026-01-18T20:55:49.556Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0c/9803aa883d18c7ef197213cd2cbf73ba76472a11fe100fb7dab2884edf48/ormsgpack-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:d024b40828f1dde5654faebd0d824f9cc29ad46891f626272dd5bfd7af2333a4", size = 117462, upload-time = "2026-01-18T20:55:47.726Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9e/029e898298b2cc662f10d7a15652a53e3b525b1e7f07e21fef8536a09bb8/ormsgpack-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:da538c542bac7d1c8f3f2a937863dba36f013108ce63e55745941dda4b75dbb6", size = 111559, upload-time = "2026-01-18T20:55:54.273Z" }, + { url = "https://files.pythonhosted.org/packages/eb/29/bb0eba3288c0449efbb013e9c6f58aea79cf5cb9ee1921f8865f04c1a9d7/ormsgpack-1.12.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5ea60cb5f210b1cfbad8c002948d73447508e629ec375acb82910e3efa8ff355", size = 378661, upload-time = "2026-01-18T20:55:57.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/5efa31346affdac489acade2926989e019e8ca98129658a183e3add7af5e/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3601f19afdbea273ed70b06495e5794606a8b690a568d6c996a90d7255e51c1", size = 203194, upload-time = "2026-01-18T20:56:08.252Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/d0087278beef833187e0167f8527235ebe6f6ffc2a143e9de12a98b1ce87/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29a9f17a3dac6054c0dce7925e0f4995c727f7c41859adf9b5572180f640d172", size = 210778, upload-time = "2026-01-18T20:55:17.694Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a2/072343e1413d9443e5a252a8eb591c2d5b1bffbe5e7bfc78c069361b92eb/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39c1bd2092880e413902910388be8715f70b9f15f20779d44e673033a6146f2d", size = 212592, upload-time = "2026-01-18T20:55:32.747Z" }, + { url = "https://files.pythonhosted.org/packages/a2/8b/a0da3b98a91d41187a63b02dda14267eefc2a74fcb43cc2701066cf1510e/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50b7249244382209877deedeee838aef1542f3d0fc28b8fe71ca9d7e1896a0d7", size = 387164, upload-time = "2026-01-18T20:55:40.853Z" }, + { url = "https://files.pythonhosted.org/packages/19/bb/6d226bc4cf9fc20d8eb1d976d027a3f7c3491e8f08289a2e76abe96a65f3/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5af04800d844451cf102a59c74a841324868d3f1625c296a06cc655c542a6685", size = 482516, upload-time = "2026-01-18T20:55:42.033Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f1/bb2c7223398543dedb3dbf8bb93aaa737b387de61c5feaad6f908841b782/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cec70477d4371cd524534cd16472d8b9cc187e0e3043a8790545a9a9b296c258", size = 425539, upload-time = "2026-01-18T20:55:24.727Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e8/0fb45f57a2ada1fed374f7494c8cd55e2f88ccd0ab0a669aa3468716bf5f/ormsgpack-1.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:21f4276caca5c03a818041d637e4019bc84f9d6ca8baa5ea03e5cc8bf56140e9", size = 117459, upload-time = "2026-01-18T20:55:56.876Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d4/0cfeea1e960d550a131001a7f38a5132c7ae3ebde4c82af1f364ccc5d904/ormsgpack-1.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:baca4b6773d20a82e36d6fd25f341064244f9f86a13dead95dd7d7f996f51709", size = 111577, upload-time = "2026-01-18T20:55:43.605Z" }, + { url = "https://files.pythonhosted.org/packages/94/16/24d18851334be09c25e87f74307c84950f18c324a4d3c0b41dabdbf19c29/ormsgpack-1.12.2-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bc68dd5915f4acf66ff2010ee47c8906dc1cf07399b16f4089f8c71733f6e36c", size = 378717, upload-time = "2026-01-18T20:55:26.164Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a2/88b9b56f83adae8032ac6a6fa7f080c65b3baf9b6b64fd3d37bd202991d4/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46d084427b4132553940070ad95107266656cb646ea9da4975f85cb1a6676553", size = 203183, upload-time = "2026-01-18T20:55:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/43e4555963bf602e5bdc79cbc8debd8b6d5456c00d2504df9775e74b450b/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c010da16235806cf1d7bc4c96bf286bfa91c686853395a299b3ddb49499a3e13", size = 210814, upload-time = "2026-01-18T20:55:33.973Z" }, + { url = "https://files.pythonhosted.org/packages/78/e1/7cfbf28de8bca6efe7e525b329c31277d1b64ce08dcba723971c241a9d60/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18867233df592c997154ff942a6503df274b5ac1765215bceba7a231bea2745d", size = 212634, upload-time = "2026-01-18T20:55:28.634Z" }, + { url = "https://files.pythonhosted.org/packages/95/f8/30ae5716e88d792a4e879debee195653c26ddd3964c968594ddef0a3cc7e/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b009049086ddc6b8f80c76b3955df1aa22a5fbd7673c525cd63bf91f23122ede", size = 387139, upload-time = "2026-01-18T20:56:02.013Z" }, + { url = "https://files.pythonhosted.org/packages/dc/81/aee5b18a3e3a0e52f718b37ab4b8af6fae0d9d6a65103036a90c2a8ffb5d/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1dcc17d92b6390d4f18f937cf0b99054824a7815818012ddca925d6e01c2e49e", size = 482578, upload-time = "2026-01-18T20:55:35.117Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/71c9ba472d5d45f7546317f467a5fc941929cd68fb32796ca3d13dcbaec2/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f04b5e896d510b07c0ad733d7fce2d44b260c5e6c402d272128f8941984e4285", size = 425539, upload-time = "2026-01-18T20:56:04.009Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a6/ac99cd7fe77e822fed5250ff4b86fa66dd4238937dd178d2299f10b69816/ormsgpack-1.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:ae3aba7eed4ca7cb79fd3436eddd29140f17ea254b91604aa1eb19bfcedb990f", size = 117493, upload-time = "2026-01-18T20:56:07.343Z" }, + { url = "https://files.pythonhosted.org/packages/3a/67/339872846a1ae4592535385a1c1f93614138566d7af094200c9c3b45d1e5/ormsgpack-1.12.2-cp314-cp314-win_arm64.whl", hash = "sha256:118576ea6006893aea811b17429bfc561b4778fad393f5f538c84af70b01260c", size = 111579, upload-time = "2026-01-18T20:55:21.161Z" }, + { url = "https://files.pythonhosted.org/packages/49/c2/6feb972dc87285ad381749d3882d8aecbde9f6ecf908dd717d33d66df095/ormsgpack-1.12.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7121b3d355d3858781dc40dafe25a32ff8a8242b9d80c692fd548a4b1f7fd3c8", size = 378721, upload-time = "2026-01-18T20:55:52.12Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9a/900a6b9b413e0f8a471cf07830f9cf65939af039a362204b36bd5b581d8b/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ee766d2e78251b7a63daf1cddfac36a73562d3ddef68cacfb41b2af64698033", size = 203170, upload-time = "2026-01-18T20:55:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/87/4c/27a95466354606b256f24fad464d7c97ab62bce6cc529dd4673e1179b8fb/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:292410a7d23de9b40444636b9b8f1e4e4b814af7f1ef476e44887e52a123f09d", size = 212816, upload-time = "2026-01-18T20:55:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/73/cd/29cee6007bddf7a834e6cd6f536754c0535fcb939d384f0f37a38b1cddb8/ormsgpack-1.12.2-cp314-cp314t-win_amd64.whl", hash = "sha256:837dd316584485b72ef451d08dd3e96c4a11d12e4963aedb40e08f89685d8ec2", size = 117232, upload-time = "2026-01-18T20:55:45.448Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -3098,6 +3370,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "ply" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/69/882ee5c9d017149285cab114ebeab373308ef0f874fcdac9beb90e0ac4da/ply-3.11.tar.gz", hash = "sha256:00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3", size = 159130, upload-time = "2018-02-15T19:01:31.097Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl", hash = "sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce", size = 49567, upload-time = "2018-02-15T19:01:27.172Z" }, +] + [[package]] name = "preshed" version = "3.0.12" @@ -3288,6 +3569,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/ad/33b2ccec09bf96c2b2ef3f9a6f66baac8253d7565d8839e024a6b905d45d/psutil-7.1.3-cp37-abi3-win_arm64.whl", hash = "sha256:bd0d69cee829226a761e92f28140bec9a5ee9d5b4fb4b0cc589068dbfff559b1", size = 244608, upload-time = "2025-11-02T12:26:36.136Z" }, ] +[[package]] +name = "psycopg" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/1a/7d9ef4fdc13ef7f15b934c393edc97a35c281bb7d3c3329fbfcbe915a7c2/psycopg-3.3.2.tar.gz", hash = "sha256:707a67975ee214d200511177a6a80e56e654754c9afca06a7194ea6bbfde9ca7", size = 165630, upload-time = "2025-12-06T17:34:53.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/51/2779ccdf9305981a06b21a6b27e8547c948d85c41c76ff434192784a4c93/psycopg-3.3.2-py3-none-any.whl", hash = "sha256:3e94bc5f4690247d734599af56e51bae8e0db8e4311ea413f801fef82b14a99b", size = 212774, upload-time = "2025-12-06T17:31:41.414Z" }, +] + +[[package]] +name = "psycopg-pool" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/9a/9470d013d0d50af0da9c4251614aeb3c1823635cab3edc211e3839db0bcf/psycopg_pool-3.3.0.tar.gz", hash = "sha256:fa115eb2860bd88fce1717d75611f41490dec6135efb619611142b24da3f6db5", size = 31606, upload-time = "2025-12-01T11:34:33.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c3/26b8a0908a9db249de3b4169692e1c7c19048a9bc41a4d3209cee7dbb758/psycopg_pool-3.3.0-py3-none-any.whl", hash = "sha256:2e44329155c410b5e8666372db44276a8b1ebd8c90f1c3026ebba40d4bc81063", size = 39995, upload-time = "2025-12-01T11:34:29.761Z" }, +] + [[package]] name = "py-cpuinfo" version = "9.0.0" @@ -6385,6 +6691,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] +[[package]] +name = "python-ulid" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/7e/0d6c82b5ccc71e7c833aed43d9e8468e1f2ff0be1b3f657a6fcafbb8433d/python_ulid-3.1.0.tar.gz", hash = "sha256:ff0410a598bc5f6b01b602851a3296ede6f91389f913a5d5f8c496003836f636", size = 93175, upload-time = "2025-08-18T16:09:26.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/a0/4ed6632b70a52de845df056654162acdebaf97c20e3212c559ac43e7216e/python_ulid-3.1.0-py3-none-any.whl", hash = "sha256:e2cdc979c8c877029b4b7a38a6fba3bc4578e4f109a308419ff4d3ccf0a46619", size = 11577, upload-time = "2025-08-18T16:09:25.047Z" }, +] + [[package]] name = "python-xlib" version = "0.33" @@ -6543,6 +6858,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "redis" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, +] + +[[package]] +name = "redisvl" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpath-ng" }, + { name = "ml-dtypes" }, + { name = "numpy" }, + { name = "pydantic" }, + { name = "python-ulid" }, + { name = "pyyaml" }, + { name = "redis" }, + { name = "tenacity" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/d6/8f3235b272e3a2370698d7524aad2dec15f53c5be5d6726ba41056844f69/redisvl-0.13.2.tar.gz", hash = "sha256:f34c4350922ac469c45d90b5db65c49950e6aa8706331931b000f631ff9a0f4a", size = 737736, upload-time = "2025-12-19T09:22:07.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/93/81ea5c45637ce7fe2fdaf214d5e1b91afe96a472edeb9b659e24d3710dfb/redisvl-0.13.2-py3-none-any.whl", hash = "sha256:dd998c6acc54f13526d464ad6b6e6f0c4cf6985fb2c7a1655bdf8ed8e57a4c01", size = 192760, upload-time = "2025-12-19T09:22:06.301Z" }, +] + [[package]] name = "regex" version = "2026.1.15" @@ -6659,6 +7002,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, ] +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + [[package]] name = "rich" version = "14.2.0" @@ -7265,6 +7620,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" }, ] +[[package]] +name = "tenacity" +version = "9.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, +] + [[package]] name = "tensorboardx" version = "2.6.4" @@ -7744,6 +8108,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" }, ] +[[package]] +name = "uuid-utils" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/7c/3a926e847516e67bc6838634f2e54e24381105b4e80f9338dc35cca0086b/uuid_utils-0.14.0.tar.gz", hash = "sha256:fc5bac21e9933ea6c590433c11aa54aaca599f690c08069e364eb13a12f670b4", size = 22072, upload-time = "2026-01-20T20:37:15.729Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/42/42d003f4a99ddc901eef2fd41acb3694163835e037fb6dde79ad68a72342/uuid_utils-0.14.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:f6695c0bed8b18a904321e115afe73b34444bc8451d0ce3244a1ec3b84deb0e5", size = 601786, upload-time = "2026-01-20T20:37:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/96/e6/775dfb91f74b18f7207e3201eb31ee666d286579990dc69dd50db2d92813/uuid_utils-0.14.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:4f0a730bbf2d8bb2c11b93e1005e91769f2f533fa1125ed1f00fd15b6fcc732b", size = 303943, upload-time = "2026-01-20T20:37:18.767Z" }, + { url = "https://files.pythonhosted.org/packages/17/82/ea5f5e85560b08a1f30cdc65f75e76494dc7aba9773f679e7eaa27370229/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40ce3fd1a4fdedae618fc3edc8faf91897012469169d600133470f49fd699ed3", size = 340467, upload-time = "2026-01-20T20:37:11.794Z" }, + { url = "https://files.pythonhosted.org/packages/ca/33/54b06415767f4569882e99b6470c6c8eeb97422686a6d432464f9967fd91/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:09ae4a98416a440e78f7d9543d11b11cae4bab538b7ed94ec5da5221481748f2", size = 346333, upload-time = "2026-01-20T20:37:12.818Z" }, + { url = "https://files.pythonhosted.org/packages/cb/10/a6bce636b8f95e65dc84bf4a58ce8205b8e0a2a300a38cdbc83a3f763d27/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:971e8c26b90d8ae727e7f2ac3ee23e265971d448b3672882f2eb44828b2b8c3e", size = 470859, upload-time = "2026-01-20T20:37:01.512Z" }, + { url = "https://files.pythonhosted.org/packages/8a/27/84121c51ea72f013f0e03d0886bcdfa96b31c9b83c98300a7bd5cc4fa191/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5cde1fa82804a8f9d2907b7aec2009d440062c63f04abbdb825fce717a5e860", size = 341988, upload-time = "2026-01-20T20:37:22.881Z" }, + { url = "https://files.pythonhosted.org/packages/90/a4/01c1c7af5e6a44f20b40183e8dac37d6ed83e7dc9e8df85370a15959b804/uuid_utils-0.14.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7343862a2359e0bd48a7f3dfb5105877a1728677818bb694d9f40703264a2db", size = 365784, upload-time = "2026-01-20T20:37:10.808Z" }, + { url = "https://files.pythonhosted.org/packages/04/f0/65ee43ec617b8b6b1bf2a5aecd56a069a08cca3d9340c1de86024331bde3/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c51e4818fdb08ccec12dc7083a01f49507b4608770a0ab22368001685d59381b", size = 523750, upload-time = "2026-01-20T20:37:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/95/d3/6bf503e3f135a5dfe705a65e6f89f19bccd55ac3fb16cb5d3ec5ba5388b8/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:181bbcccb6f93d80a8504b5bd47b311a1c31395139596edbc47b154b0685b533", size = 615818, upload-time = "2026-01-20T20:37:21.816Z" }, + { url = "https://files.pythonhosted.org/packages/df/6c/99937dd78d07f73bba831c8dc9469dfe4696539eba2fc269ae1b92752f9e/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:5c8ae96101c3524ba8dbf762b6f05e9e9d896544786c503a727c5bf5cb9af1a7", size = 580831, upload-time = "2026-01-20T20:37:19.691Z" }, + { url = "https://files.pythonhosted.org/packages/44/fa/bbc9e2c25abd09a293b9b097a0d8fc16acd6a92854f0ec080f1ea7ad8bb3/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:00ac3c6edfdaff7e1eed041f4800ae09a3361287be780d7610a90fdcde9befdc", size = 546333, upload-time = "2026-01-20T20:37:03.117Z" }, + { url = "https://files.pythonhosted.org/packages/e7/9b/e5e99b324b1b5f0c62882230455786df0bc66f67eff3b452447e703f45d2/uuid_utils-0.14.0-cp39-abi3-win32.whl", hash = "sha256:ec2fd80adf8e0e6589d40699e6f6df94c93edcc16dd999be0438dd007c77b151", size = 177319, upload-time = "2026-01-20T20:37:04.208Z" }, + { url = "https://files.pythonhosted.org/packages/d3/28/2c7d417ea483b6ff7820c948678fdf2ac98899dc7e43bb15852faa95acaf/uuid_utils-0.14.0-cp39-abi3-win_amd64.whl", hash = "sha256:efe881eb43a5504fad922644cb93d725fd8a6a6d949bd5a4b4b7d1a1587c7fd1", size = 182566, upload-time = "2026-01-20T20:37:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/b8/86/49e4bdda28e962fbd7266684171ee29b3d92019116971d58783e51770745/uuid_utils-0.14.0-cp39-abi3-win_arm64.whl", hash = "sha256:32b372b8fd4ebd44d3a219e093fe981af4afdeda2994ee7db208ab065cfcd080", size = 182809, upload-time = "2026-01-20T20:37:05.139Z" }, +] + [[package]] name = "wasabi" version = "1.1.3" @@ -7941,6 +8327,89 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, ] +[[package]] +name = "xxhash" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160, upload-time = "2025-10-02T14:37:08.097Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/07/d9412f3d7d462347e4511181dea65e47e0d0e16e26fbee2ea86a2aefb657/xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c", size = 32744, upload-time = "2025-10-02T14:34:34.622Z" }, + { url = "https://files.pythonhosted.org/packages/79/35/0429ee11d035fc33abe32dca1b2b69e8c18d236547b9a9b72c1929189b9a/xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204", size = 30816, upload-time = "2025-10-02T14:34:36.043Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f2/57eb99aa0f7d98624c0932c5b9a170e1806406cdbcdb510546634a1359e0/xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490", size = 194035, upload-time = "2025-10-02T14:34:37.354Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ed/6224ba353690d73af7a3f1c7cdb1fc1b002e38f783cb991ae338e1eb3d79/xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2", size = 212914, upload-time = "2025-10-02T14:34:38.6Z" }, + { url = "https://files.pythonhosted.org/packages/38/86/fb6b6130d8dd6b8942cc17ab4d90e223653a89aa32ad2776f8af7064ed13/xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa", size = 212163, upload-time = "2025-10-02T14:34:39.872Z" }, + { url = "https://files.pythonhosted.org/packages/ee/dc/e84875682b0593e884ad73b2d40767b5790d417bde603cceb6878901d647/xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0", size = 445411, upload-time = "2025-10-02T14:34:41.569Z" }, + { url = "https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2", size = 193883, upload-time = "2025-10-02T14:34:43.249Z" }, + { url = "https://files.pythonhosted.org/packages/53/5a/ddbb83eee8e28b778eacfc5a85c969673e4023cdeedcfcef61f36731610b/xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9", size = 210392, upload-time = "2025-10-02T14:34:45.042Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c2/ff69efd07c8c074ccdf0a4f36fcdd3d27363665bcdf4ba399abebe643465/xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e", size = 197898, upload-time = "2025-10-02T14:34:46.302Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/faa05ac19b3b622c7c9317ac3e23954187516298a091eb02c976d0d3dd45/xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374", size = 210655, upload-time = "2025-10-02T14:34:47.571Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7a/06aa7482345480cc0cb597f5c875b11a82c3953f534394f620b0be2f700c/xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d", size = 414001, upload-time = "2025-10-02T14:34:49.273Z" }, + { url = "https://files.pythonhosted.org/packages/23/07/63ffb386cd47029aa2916b3d2f454e6cc5b9f5c5ada3790377d5430084e7/xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae", size = 191431, upload-time = "2025-10-02T14:34:50.798Z" }, + { url = "https://files.pythonhosted.org/packages/0f/93/14fde614cadb4ddf5e7cebf8918b7e8fac5ae7861c1875964f17e678205c/xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb", size = 30617, upload-time = "2025-10-02T14:34:51.954Z" }, + { url = "https://files.pythonhosted.org/packages/13/5d/0d125536cbe7565a83d06e43783389ecae0c0f2ed037b48ede185de477c0/xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c", size = 31534, upload-time = "2025-10-02T14:34:53.276Z" }, + { url = "https://files.pythonhosted.org/packages/54/85/6ec269b0952ec7e36ba019125982cf11d91256a778c7c3f98a4c5043d283/xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829", size = 27876, upload-time = "2025-10-02T14:34:54.371Z" }, + { url = "https://files.pythonhosted.org/packages/33/76/35d05267ac82f53ae9b0e554da7c5e281ee61f3cad44c743f0fcd354f211/xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec", size = 32738, upload-time = "2025-10-02T14:34:55.839Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/3fbce1cd96534a95e35d5120637bf29b0d7f5d8fa2f6374e31b4156dd419/xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1", size = 30821, upload-time = "2025-10-02T14:34:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ea/d387530ca7ecfa183cb358027f1833297c6ac6098223fd14f9782cd0015c/xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6", size = 194127, upload-time = "2025-10-02T14:34:59.21Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0c/71435dcb99874b09a43b8d7c54071e600a7481e42b3e3ce1eb5226a5711a/xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263", size = 212975, upload-time = "2025-10-02T14:35:00.816Z" }, + { url = "https://files.pythonhosted.org/packages/84/7a/c2b3d071e4bb4a90b7057228a99b10d51744878f4a8a6dd643c8bd897620/xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546", size = 212241, upload-time = "2025-10-02T14:35:02.207Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/640b6eac0128e215f177df99eadcd0f1b7c42c274ab6a394a05059694c5a/xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89", size = 445471, upload-time = "2025-10-02T14:35:03.61Z" }, + { url = "https://files.pythonhosted.org/packages/5e/1e/3c3d3ef071b051cc3abbe3721ffb8365033a172613c04af2da89d5548a87/xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d", size = 193936, upload-time = "2025-10-02T14:35:05.013Z" }, + { url = "https://files.pythonhosted.org/packages/2c/bd/4a5f68381939219abfe1c22a9e3a5854a4f6f6f3c4983a87d255f21f2e5d/xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7", size = 210440, upload-time = "2025-10-02T14:35:06.239Z" }, + { url = "https://files.pythonhosted.org/packages/eb/37/b80fe3d5cfb9faff01a02121a0f4d565eb7237e9e5fc66e73017e74dcd36/xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db", size = 197990, upload-time = "2025-10-02T14:35:07.735Z" }, + { url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689, upload-time = "2025-10-02T14:35:09.438Z" }, + { url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068, upload-time = "2025-10-02T14:35:11.162Z" }, + { url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495, upload-time = "2025-10-02T14:35:12.971Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620, upload-time = "2025-10-02T14:35:14.129Z" }, + { url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542, upload-time = "2025-10-02T14:35:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880, upload-time = "2025-10-02T14:35:16.315Z" }, + { url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956, upload-time = "2025-10-02T14:35:17.413Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072, upload-time = "2025-10-02T14:35:18.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409, upload-time = "2025-10-02T14:35:20.31Z" }, + { url = "https://files.pythonhosted.org/packages/e3/8e/c6d158d12a79bbd0b878f8355432075fc82759e356ab5a111463422a239b/xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f", size = 215736, upload-time = "2025-10-02T14:35:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/bc/68/c4c80614716345d55071a396cf03d06e34b5f4917a467faf43083c995155/xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5", size = 214833, upload-time = "2025-10-02T14:35:23.32Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e9/ae27c8ffec8b953efa84c7c4a6c6802c263d587b9fc0d6e7cea64e08c3af/xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1", size = 448348, upload-time = "2025-10-02T14:35:25.111Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6b/33e21afb1b5b3f46b74b6bd1913639066af218d704cc0941404ca717fc57/xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee", size = 196070, upload-time = "2025-10-02T14:35:26.586Z" }, + { url = "https://files.pythonhosted.org/packages/96/b6/fcabd337bc5fa624e7203aa0fa7d0c49eed22f72e93229431752bddc83d9/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd", size = 212907, upload-time = "2025-10-02T14:35:28.087Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d3/9ee6160e644d660fcf176c5825e61411c7f62648728f69c79ba237250143/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729", size = 200839, upload-time = "2025-10-02T14:35:29.857Z" }, + { url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304, upload-time = "2025-10-02T14:35:31.222Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930, upload-time = "2025-10-02T14:35:32.517Z" }, + { url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787, upload-time = "2025-10-02T14:35:33.827Z" }, + { url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916, upload-time = "2025-10-02T14:35:35.107Z" }, + { url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799, upload-time = "2025-10-02T14:35:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044, upload-time = "2025-10-02T14:35:37.195Z" }, + { url = "https://files.pythonhosted.org/packages/7e/5e/0138bc4484ea9b897864d59fce9be9086030825bc778b76cb5a33a906d37/xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e", size = 32754, upload-time = "2025-10-02T14:35:38.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/d7/5dac2eb2ec75fd771957a13e5dda560efb2176d5203f39502a5fc571f899/xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405", size = 30846, upload-time = "2025-10-02T14:35:39.6Z" }, + { url = "https://files.pythonhosted.org/packages/fe/71/8bc5be2bb00deb5682e92e8da955ebe5fa982da13a69da5a40a4c8db12fb/xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3", size = 194343, upload-time = "2025-10-02T14:35:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/52badfb2aecec2c377ddf1ae75f55db3ba2d321c5e164f14461c90837ef3/xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6", size = 213074, upload-time = "2025-10-02T14:35:42.29Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2b/ae46b4e9b92e537fa30d03dbc19cdae57ed407e9c26d163895e968e3de85/xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063", size = 212388, upload-time = "2025-10-02T14:35:43.929Z" }, + { url = "https://files.pythonhosted.org/packages/f5/80/49f88d3afc724b4ac7fbd664c8452d6db51b49915be48c6982659e0e7942/xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7", size = 445614, upload-time = "2025-10-02T14:35:45.216Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ba/603ce3961e339413543d8cd44f21f2c80e2a7c5cfe692a7b1f2cccf58f3c/xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b", size = 194024, upload-time = "2025-10-02T14:35:46.959Z" }, + { url = "https://files.pythonhosted.org/packages/78/d1/8e225ff7113bf81545cfdcd79eef124a7b7064a0bba53605ff39590b95c2/xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd", size = 210541, upload-time = "2025-10-02T14:35:48.301Z" }, + { url = "https://files.pythonhosted.org/packages/6f/58/0f89d149f0bad89def1a8dd38feb50ccdeb643d9797ec84707091d4cb494/xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0", size = 198305, upload-time = "2025-10-02T14:35:49.584Z" }, + { url = "https://files.pythonhosted.org/packages/11/38/5eab81580703c4df93feb5f32ff8fa7fe1e2c51c1f183ee4e48d4bb9d3d7/xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152", size = 210848, upload-time = "2025-10-02T14:35:50.877Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6b/953dc4b05c3ce678abca756416e4c130d2382f877a9c30a20d08ee6a77c0/xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11", size = 414142, upload-time = "2025-10-02T14:35:52.15Z" }, + { url = "https://files.pythonhosted.org/packages/08/a9/238ec0d4e81a10eb5026d4a6972677cbc898ba6c8b9dbaec12ae001b1b35/xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5", size = 191547, upload-time = "2025-10-02T14:35:53.547Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ee/3cf8589e06c2164ac77c3bf0aa127012801128f1feebf2a079272da5737c/xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f", size = 31214, upload-time = "2025-10-02T14:35:54.746Z" }, + { url = "https://files.pythonhosted.org/packages/02/5d/a19552fbc6ad4cb54ff953c3908bbc095f4a921bc569433d791f755186f1/xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad", size = 32290, upload-time = "2025-10-02T14:35:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/b1/11/dafa0643bc30442c887b55baf8e73353a344ee89c1901b5a5c54a6c17d39/xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679", size = 28795, upload-time = "2025-10-02T14:35:57.162Z" }, + { url = "https://files.pythonhosted.org/packages/2c/db/0e99732ed7f64182aef4a6fb145e1a295558deec2a746265dcdec12d191e/xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4", size = 32955, upload-time = "2025-10-02T14:35:58.267Z" }, + { url = "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67", size = 31072, upload-time = "2025-10-02T14:35:59.382Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d9/72a29cddc7250e8a5819dad5d466facb5dc4c802ce120645630149127e73/xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad", size = 196579, upload-time = "2025-10-02T14:36:00.838Z" }, + { url = "https://files.pythonhosted.org/packages/63/93/b21590e1e381040e2ca305a884d89e1c345b347404f7780f07f2cdd47ef4/xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b", size = 215854, upload-time = "2025-10-02T14:36:02.207Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b8/edab8a7d4fa14e924b29be877d54155dcbd8b80be85ea00d2be3413a9ed4/xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b", size = 214965, upload-time = "2025-10-02T14:36:03.507Z" }, + { url = "https://files.pythonhosted.org/packages/27/67/dfa980ac7f0d509d54ea0d5a486d2bb4b80c3f1bb22b66e6a05d3efaf6c0/xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca", size = 448484, upload-time = "2025-10-02T14:36:04.828Z" }, + { url = "https://files.pythonhosted.org/packages/8c/63/8ffc2cc97e811c0ca5d00ab36604b3ea6f4254f20b7bc658ca825ce6c954/xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a", size = 196162, upload-time = "2025-10-02T14:36:06.182Z" }, + { url = "https://files.pythonhosted.org/packages/4b/77/07f0e7a3edd11a6097e990f6e5b815b6592459cb16dae990d967693e6ea9/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99", size = 213007, upload-time = "2025-10-02T14:36:07.733Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d8/bc5fa0d152837117eb0bef6f83f956c509332ce133c91c63ce07ee7c4873/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3", size = 200956, upload-time = "2025-10-02T14:36:09.106Z" }, + { url = "https://files.pythonhosted.org/packages/26/a5/d749334130de9411783873e9b98ecc46688dad5db64ca6e04b02acc8b473/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6", size = 213401, upload-time = "2025-10-02T14:36:10.585Z" }, + { url = "https://files.pythonhosted.org/packages/89/72/abed959c956a4bfc72b58c0384bb7940663c678127538634d896b1195c10/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93", size = 417083, upload-time = "2025-10-02T14:36:12.276Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b3/62fd2b586283b7d7d665fb98e266decadf31f058f1cf6c478741f68af0cb/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518", size = 193913, upload-time = "2025-10-02T14:36:14.025Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/c19c42c5b3f5a4aad748a6d5b4f23df3bed7ee5445accc65a0fb3ff03953/xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119", size = 31586, upload-time = "2025-10-02T14:36:15.603Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/4cc450345be9924fd5dc8c590ceda1db5b43a0a889587b0ae81a95511360/xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f", size = 32526, upload-time = "2025-10-02T14:36:16.708Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898, upload-time = "2025-10-02T14:36:17.843Z" }, +] + [[package]] name = "yarl" version = "1.22.0" @@ -8058,3 +8527,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/95/db/4f2eebf73c0e2df293a366a1d176cd315a74ce0b00f83826a7ba9ddd1ab3/zopfli-0.4.0-cp310-abi3-win32.whl", hash = "sha256:03181d48e719fcb6cf8340189c61e8f9883d8bbbdf76bf5212a74457f7d083c1", size = 83655, upload-time = "2025-11-07T17:00:51.797Z" }, { url = "https://files.pythonhosted.org/packages/24/f6/bd80c5278b1185dc41155c77bc61bfe1d817254a7f2115f66aa69a270b89/zopfli-0.4.0-cp310-abi3-win_amd64.whl", hash = "sha256:f94e4dd7d76b4fe9f5d9229372be20d7f786164eea5152d1af1c34298c3d5975", size = 100824, upload-time = "2025-11-07T17:00:52.658Z" }, ] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, + { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, + { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, + { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, + { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, +]