diff --git a/backend/app/alembic/versions/076_add_evaluation_iteration_run.py b/backend/app/alembic/versions/076_add_evaluation_iteration_run.py new file mode 100644 index 000000000..f4a1efd11 --- /dev/null +++ b/backend/app/alembic/versions/076_add_evaluation_iteration_run.py @@ -0,0 +1,127 @@ +"""Add evaluation_iteration_run table + +Revision ID: 076 +Revises: 075 +Create Date: 2026-08-02 00:00:00.000000 + +Thin tracking row for the eval-iterate-improve LangGraph loop (see +docs/srd-ai-prompt-improvement.md follow-on: the Evaluation Iteration Loop). The +round-by-round trajectory itself lives in the LangGraph checkpoint (owned by +`langgraph-checkpoint-postgres`, set up separately via `checkpointer.setup()`, +not this migration) — this table only tracks enough to create/look up a loop, +scope it by org/project, and let the cron tick find loops still in flight. +""" + +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from alembic import op + +revision = "076" +down_revision = "075" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "evaluation_iteration_run", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("dataset_id", sa.Integer(), nullable=False), + sa.Column( + "experiment_name", + sqlmodel.sql.sqltypes.AutoString(length=255), + nullable=False, + ), + sa.Column("config_id", sa.Uuid(), nullable=False), + sa.Column("initial_config_version", sa.Integer(), nullable=False), + sa.Column( + "status", + sa.Enum( + "processing", + "completed", + "failed", + name="evaluationiterationstatusenum", + ), + nullable=False, + comment="Loop bookkeeping status: processing, completed, or failed", + ), + sa.Column( + "stop_reason", + sqlmodel.sql.sqltypes.AutoString(), + nullable=True, + comment="Copied from the final graph state once terminal: ceiling_reached, max_rounds_reached, or round_failed", + ), + sa.Column( + "callback_url", + sqlmodel.sql.sqltypes.AutoString(), + nullable=False, + comment="HTTPS webhook validated via validate_callback_url before create", + ), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("organization_id", sa.Integer(), nullable=False), + sa.Column("project_id", sa.Integer(), nullable=False), + sa.Column("inserted_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["dataset_id"], + ["evaluation_dataset.id"], + name="fk_evaluation_iteration_run_dataset_id", + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["config_id"], + ["config.id"], + name="fk_evaluation_iteration_run_config_id", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["organization_id"], ["organization.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["project_id"], ["project.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_evaluation_iteration_run_dataset_id"), + "evaluation_iteration_run", + ["dataset_id"], + unique=False, + ) + op.create_index( + op.f("ix_evaluation_iteration_run_config_id"), + "evaluation_iteration_run", + ["config_id"], + unique=False, + ) + op.create_index( + op.f("ix_evaluation_iteration_run_organization_id"), + "evaluation_iteration_run", + ["organization_id"], + unique=False, + ) + op.create_index( + op.f("ix_evaluation_iteration_run_project_id"), + "evaluation_iteration_run", + ["project_id"], + unique=False, + ) + + +def downgrade(): + op.drop_index( + op.f("ix_evaluation_iteration_run_project_id"), + table_name="evaluation_iteration_run", + ) + op.drop_index( + op.f("ix_evaluation_iteration_run_organization_id"), + table_name="evaluation_iteration_run", + ) + op.drop_index( + op.f("ix_evaluation_iteration_run_config_id"), + table_name="evaluation_iteration_run", + ) + op.drop_index( + op.f("ix_evaluation_iteration_run_dataset_id"), + table_name="evaluation_iteration_run", + ) + op.drop_table("evaluation_iteration_run") + sa.Enum(name="evaluationiterationstatusenum").drop(op.get_bind(), checkfirst=True) diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 9e96e7fc4..7e2178fe9 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -41,6 +41,9 @@ router as evaluations_dataset_v2_router, ) from app.api.routes.evaluations.evaluation_v2 import router as evaluations_v2_router +from app.api.routes.evaluations.iteration_v2 import ( + router as evaluations_iteration_v2_router, +) from app.api.routes.evaluations.prompt_improvement_v2 import ( router as evaluations_prompt_improvement_v2_router, ) @@ -89,3 +92,4 @@ api_v2_router.include_router(evaluations_v2_router) api_v2_router.include_router(evaluations_dataset_v2_router) api_v2_router.include_router(evaluations_prompt_improvement_v2_router) +api_v2_router.include_router(evaluations_iteration_v2_router) diff --git a/backend/app/api/routes/evaluations/iteration_v2.py b/backend/app/api/routes/evaluations/iteration_v2.py new file mode 100644 index 000000000..a3b48ab03 --- /dev/null +++ b/backend/app/api/routes/evaluations/iteration_v2.py @@ -0,0 +1,68 @@ +"""v2 evaluation iteration loop trigger — chains eval -> improve-prompt -> eval.""" + +import logging + +from asgi_correlation_id import correlation_id +from fastapi import APIRouter, Depends, HTTPException + +from app.api.deps import AuthContextDep, SessionDep +from app.api.permissions import Permission, require_permission +from app.core.rate_monitor import monitor_rate +from app.models.evaluation_iteration import ( + EvaluationIterationCreateRequest, + EvaluationIterationRunImmediatePublic, +) +from app.services.evaluations.iteration import validate_and_start_evaluation_iteration +from app.utils import APIResponse, load_description, validate_callback_url + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/evaluations", tags=["Evaluation v2"]) + + +@router.post( + "/iterations", + description=load_description("evaluation/create_evaluation_iteration_v2.md"), + response_model=APIResponse[EvaluationIterationRunImmediatePublic], + status_code=202, + dependencies=[ + Depends(require_permission(Permission.REQUIRE_PROJECT)), + Depends(monitor_rate("evaluations")), + ], +) +def create_evaluation_iteration_v2( + session: SessionDep, + auth_context: AuthContextDep, + request: EvaluationIterationCreateRequest, +) -> APIResponse[EvaluationIterationRunImmediatePublic]: + """Kick off a self-driving eval -> improve-prompt -> eval loop.""" + try: + validate_callback_url(str(request.callback_url)) + except ValueError as exc: + raise HTTPException(status_code=422, detail=f"invalid_callback_url: {exc}") + + iteration_run = validate_and_start_evaluation_iteration( + session=session, + dataset_id=request.dataset_id, + experiment_name=request.experiment_name, + config_id=request.config_id, + config_version=request.config_version, + max_rounds=request.max_rounds, + callback_url=str(request.callback_url), + organization_id=auth_context.organization_.id, + project_id=auth_context.project_.id, + trace_id=correlation_id.get() or "N/A", + ) + + return APIResponse.success_response( + data=EvaluationIterationRunImmediatePublic( + iteration_run_id=iteration_run.id, + status=iteration_run.status, + message=( + "Evaluation iteration loop is running; the round-by-round report " + "will be delivered to your callback_url." + ), + inserted_at=iteration_run.inserted_at, + updated_at=iteration_run.updated_at, + ) + ) diff --git a/backend/app/celery/tasks/job_execution.py b/backend/app/celery/tasks/job_execution.py index 000c6025a..30b042568 100644 --- a/backend/app/celery/tasks/job_execution.py +++ b/backend/app/celery/tasks/job_execution.py @@ -4,7 +4,8 @@ ordered by the per-task `priority`: 9 LLM call + LLM chain (run_llm_job, run_llm_chain_job, run_response_job) - 6 Fast evaluation (run_evaluation_fast_chunk, run_evaluation_fast_aggregate) + 6 Fast evaluation (run_evaluation_fast_chunk, run_evaluation_fast_aggregate, + run_prompt_improvement, run_evaluation_iteration_graph_step) 2 Everything else (doctransform, collections, STT/TTS evaluation, assessment) 1 Notifications (send_eval_completion_notification) @@ -425,6 +426,52 @@ def run_evaluation_fast_aggregate( ) +# Priority 6 (fast-eval tier): same band as run_prompt_improvement — one graph +# step per invocation is either a cheap status re-check (re-interrupts) or the +# same-cost work run_prompt_improvement/run_evaluation_fast_* already does. +@celery_app.task(bind=True, queue="default", priority=6) +@gevent_timeout( + settings.CELERY_TASK_SOFT_TIME_LIMIT, "run_evaluation_iteration_graph_step" +) +def run_evaluation_iteration_graph_step( + self, + iteration_run_id: int, + resume: bool, + organization_id: int, + project_id: int, + trace_id: str = DEFAULT_TRACE_ID, + **kwargs, +): + """Advance one step of an evaluation-iteration LangGraph loop. + + `resume=False` (kickoff) invokes the graph with a fresh initial state built + from `kwargs` (`max_rounds`, `config_version`) + the thin tracking row; + `resume=True` (cron tick) sends `Command(resume=True)` to the persisted + checkpoint. Either call returns once the graph re-interrupts or reaches + `finalize_node` — see `execute_evaluation_iteration_graph_step`. + """ + from app.services.evaluations.iteration_graph import ( + execute_evaluation_iteration_graph_step, + ) + + _set_trace(trace_id) + logger.info( + f"[run_evaluation_iteration_graph_step] Starting | " + f"iteration_run_id={iteration_run_id} | resume={resume} | " + f"task_id={current_task.request.id}" + ) + return _run_with_otel_parent( + self, + lambda: execute_evaluation_iteration_graph_step( + iteration_run_id=iteration_run_id, + resume=resume, + organization_id=organization_id, + project_id=project_id, + **kwargs, + ), + ) + + @celery_app.task(bind=True, queue="default", priority=1) @gevent_timeout( settings.CELERY_TASK_SOFT_TIME_LIMIT, "send_eval_completion_notification" diff --git a/backend/app/celery/utils.py b/backend/app/celery/utils.py index 7496620f0..3bd205d22 100644 --- a/backend/app/celery/utils.py +++ b/backend/app/celery/utils.py @@ -305,6 +305,38 @@ def start_fast_evaluation_aggregate(eval_run_id: int, trace_id: str = "N/A") -> return task_id +def start_evaluation_iteration_round( + iteration_run_id: int, + resume: bool, + organization_id: int, + project_id: int, + trace_id: str = "N/A", + **kwargs: Any, +) -> str: + """Enqueue one graph step (kickoff or resume) for an evaluation iteration loop. + + `**kwargs` (`max_rounds`, `config_version`) is only meaningful on the + `resume=False` kickoff call, to seed the graph's initial state — a resume + call ignores them since the checkpoint already carries that state. + """ + from app.celery.tasks.job_execution import run_evaluation_iteration_graph_step + + task_id = _enqueue_with_trace_context( + run_evaluation_iteration_graph_step, + iteration_run_id=iteration_run_id, + resume=resume, + organization_id=organization_id, + project_id=project_id, + trace_id=trace_id, + **kwargs, + ) + logger.info( + f"[start_evaluation_iteration_round] Enqueued | " + f"iteration_run_id={iteration_run_id} | resume={resume} | task_id={task_id}" + ) + return task_id + + def get_task_status(task_id: str) -> Dict[str, Any]: result = AsyncResult(task_id) return { diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 8ebc37b53..896f7bf0c 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -211,6 +211,15 @@ def AWS_S3_BUCKET(self) -> str: # task well under CELERY_TASK_SOFT_TIME_LIMIT. EVAL_FAST_CHUNK_SIZE: int = 50 + # Evaluation iteration loop (eval -> improve-prompt -> eval, LangGraph-orchestrated). + EVAL_ITERATION_MAX_ROUNDS_DEFAULT: int = 10 + EVAL_ITERATION_MAX_ROUNDS_HARD_CAP: int = 25 + # Absolute (0-1 scale) stop-score delta below which a round counts as "no + # meaningful improvement" toward the ceiling-reached stop condition. + EVAL_ITERATION_CEILING_DELTA_THRESHOLD: float = 0.05 + # Consecutive low-delta rounds required before the loop stops as ceiling_reached. + EVAL_ITERATION_CEILING_CONSECUTIVE_ROUNDS: int = 3 + EVAL_JUDGE_MODEL: str = "gpt-5-mini" # One of: none | minimal | low | medium | high | xhigh. diff --git a/backend/app/crud/evaluations/__init__.py b/backend/app/crud/evaluations/__init__.py index dc4d9fb56..c60efd016 100644 --- a/backend/app/crud/evaluations/__init__.py +++ b/backend/app/crud/evaluations/__init__.py @@ -33,6 +33,12 @@ run_fast_evaluation, run_response_chunk, ) +from app.crud.evaluations.iteration import ( + create_evaluation_iteration_run, + get_evaluation_iteration_run_by_id, + list_processing_evaluation_iteration_runs, + update_evaluation_iteration_run, +) from app.crud.evaluations.langfuse import ( create_langfuse_dataset_run, fetch_trace_scores_from_langfuse, @@ -84,6 +90,11 @@ "list_response_chunk_jobs", "run_fast_evaluation", "run_response_chunk", + # Iteration loop + "create_evaluation_iteration_run", + "get_evaluation_iteration_run_by_id", + "list_processing_evaluation_iteration_runs", + "update_evaluation_iteration_run", # Processing "check_and_process_evaluation", "poll_all_pending_evaluations", diff --git a/backend/app/crud/evaluations/cron.py b/backend/app/crud/evaluations/cron.py index 6cfe5a708..84f860935 100644 --- a/backend/app/crud/evaluations/cron.py +++ b/backend/app/crud/evaluations/cron.py @@ -17,6 +17,7 @@ from app.core.util import now from app.crud.evaluations.core import update_evaluation_run from app.crud.evaluations.fast import CHUNK_CONFIG_INDEX, list_response_chunk_jobs +from app.crud.evaluations.iteration import list_processing_evaluation_iteration_runs from app.crud.evaluations.processing import poll_all_pending_evaluations from app.models import EvaluationRun, EvaluationRunUpdate from app.models.evaluation import RunModeEnum @@ -100,6 +101,32 @@ def dispatch_fast_evaluation_barriers(session: Session) -> dict[str, Any]: } +def dispatch_pending_evaluation_iteration_resumes(session: Session) -> dict[str, Any]: + """Resume trigger for the eval-iterate-improve LangGraph loop. + + For every thin `evaluation_iteration_run` row still `PROCESSING`, dispatch a + `resume=True` graph-step task. Cheap even when the loop is still waiting on + the same sub-job as last tick: the task just re-checks status and interrupts + again immediately if not ready — same cost profile as a plain polling barrier. + """ + from app.celery.utils import start_evaluation_iteration_round + + runs = list_processing_evaluation_iteration_runs(session=session) + for run in runs: + start_evaluation_iteration_round( + iteration_run_id=run.id, + resume=True, + organization_id=run.organization_id, + project_id=run.project_id, + ) + + logger.info( + f"[dispatch_pending_evaluation_iteration_resumes] Dispatched resumes | " + f"count={len(runs)}" + ) + return {"total": len(runs), "resumes_dispatched": len(runs)} + + async def process_all_pending_evaluations(session: Session) -> dict[str, Any]: """ Process all pending evaluations across all organizations. @@ -133,6 +160,11 @@ async def process_all_pending_evaluations(session: Session) -> dict[str, Any]: # Fan-in barrier + stall healer for chunked fast-mode text evaluations. fast_summary = dispatch_fast_evaluation_barriers(session=session) + # Resume trigger for the eval-iterate-improve LangGraph loop. + iteration_summary = dispatch_pending_evaluation_iteration_resumes( + session=session + ) + # Merge summaries total_processed = ( text_summary["processed"] @@ -158,7 +190,8 @@ async def process_all_pending_evaluations(session: Session) -> dict[str, Any]: f"{total_processed} processed, {total_failed} failed, " f"{total_still_processing} still processing | " f"fast_aggregates={fast_summary['aggregates_dispatched']} | " - f"fast_chunks_reenqueued={fast_summary['chunks_reenqueued']}" + f"fast_chunks_reenqueued={fast_summary['chunks_reenqueued']} | " + f"iteration_resumes={iteration_summary['resumes_dispatched']}" ) return { @@ -168,6 +201,7 @@ async def process_all_pending_evaluations(session: Session) -> dict[str, Any]: "total_still_processing": total_still_processing, "results": all_details, "fast": fast_summary, + "iteration": iteration_summary, } except Exception as e: diff --git a/backend/app/crud/evaluations/iteration.py b/backend/app/crud/evaluations/iteration.py new file mode 100644 index 000000000..2b7ff1961 --- /dev/null +++ b/backend/app/crud/evaluations/iteration.py @@ -0,0 +1,112 @@ +"""CRUD for the thin `evaluation_iteration_run` tracking row. + +The round-by-round trajectory lives in the LangGraph checkpoint, not here — this +module only creates/looks up/updates the one thin row per loop. +""" + +import logging +from uuid import UUID + +from sqlmodel import Session, select + +from app.core.util import now +from app.models.evaluation_iteration import ( + EvaluationIterationRun, + EvaluationIterationRunUpdate, + EvaluationIterationStatusEnum, +) + +logger = logging.getLogger(__name__) + + +def create_evaluation_iteration_run( + *, + session: Session, + dataset_id: int, + experiment_name: str, + config_id: UUID, + initial_config_version: int, + callback_url: str, + organization_id: int, + project_id: int, +) -> EvaluationIterationRun: + """Create the thin tracking row, status=PROCESSING.""" + iteration_run = EvaluationIterationRun( + dataset_id=dataset_id, + experiment_name=experiment_name, + config_id=config_id, + initial_config_version=initial_config_version, + callback_url=callback_url, + status=EvaluationIterationStatusEnum.PROCESSING, + organization_id=organization_id, + project_id=project_id, + ) + session.add(iteration_run) + session.commit() + session.refresh(iteration_run) + logger.info( + f"[create_evaluation_iteration_run] Created | " + f"iteration_run_id={iteration_run.id} | dataset_id={dataset_id} | " + f"org_id={organization_id} | project_id={project_id}" + ) + return iteration_run + + +def get_evaluation_iteration_run_by_id( + *, + session: Session, + iteration_run_id: int, + organization_id: int, + project_id: int, +) -> EvaluationIterationRun | None: + """Get one iteration run scoped to the caller's org/project.""" + statement = ( + select(EvaluationIterationRun) + .where(EvaluationIterationRun.id == iteration_run_id) + .where(EvaluationIterationRun.organization_id == organization_id) + .where(EvaluationIterationRun.project_id == project_id) + ) + iteration_run = session.exec(statement).first() + if iteration_run is None: + logger.warning( + f"[get_evaluation_iteration_run_by_id] Not found | " + f"iteration_run_id={iteration_run_id} | org_id={organization_id} | " + f"project_id={project_id}" + ) + return iteration_run + + +def list_processing_evaluation_iteration_runs( + *, session: Session +) -> list[EvaluationIterationRun]: + """Every loop still in flight — what the cron tick dispatches a resume for.""" + statement = select(EvaluationIterationRun).where( + EvaluationIterationRun.status == EvaluationIterationStatusEnum.PROCESSING + ) + runs = list(session.exec(statement).all()) + logger.info( + f"[list_processing_evaluation_iteration_runs] Found {len(runs)} processing loops" + ) + return runs + + +def update_evaluation_iteration_run( + *, + session: Session, + iteration_run: EvaluationIterationRun, + update: EvaluationIterationRunUpdate, +) -> EvaluationIterationRun: + """Partial update; only fields explicitly set on `update` are applied.""" + update_fields = update.model_dump(exclude_unset=True) + for field_name, new_value in update_fields.items(): + setattr(iteration_run, field_name, new_value) + + iteration_run.updated_at = now() + session.add(iteration_run) + session.commit() + session.refresh(iteration_run) + logger.info( + f"[update_evaluation_iteration_run] Updated | " + f"iteration_run_id={iteration_run.id} | fields={list(update_fields.keys())}" + ) + return iteration_run diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index f855398bb..863506047 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -101,6 +101,15 @@ EvaluationRunUpdate, RunModeEnum, ) +from .evaluation_iteration import ( + EvaluationIterationCreateRequest, + EvaluationIterationReportPublic, + EvaluationIterationRoundPublic, + EvaluationIterationRun, + EvaluationIterationRunImmediatePublic, + EvaluationIterationRunUpdate, + EvaluationIterationStatusEnum, +) from .feature_flag import ( FeatureFlag, FeatureFlagCreate, diff --git a/backend/app/models/evaluation_iteration.py b/backend/app/models/evaluation_iteration.py new file mode 100644 index 000000000..53af4daf0 --- /dev/null +++ b/backend/app/models/evaluation_iteration.py @@ -0,0 +1,194 @@ +from datetime import datetime +from enum import StrEnum +from uuid import UUID + +from pydantic import HttpUrl +from sqlalchemy import Column +from sqlalchemy.dialects.postgresql import ENUM +from sqlmodel import Field, SQLModel + +from app.core.util import now + + +class EvaluationIterationStatusEnum(StrEnum): + """Top-level bookkeeping only — the round-by-round state lives in the + LangGraph checkpoint, not on this row. This just answers "is this loop + still in flight?" for the cron resume tick and the API.""" + + PROCESSING = "processing" + COMPLETED = "completed" + FAILED = "failed" + + +# Without values_callable, SQLAlchemy's auto-derived Enum stores the member +# *name* (e.g. "PROCESSING"), but the migration created the Postgres enum type +# with the lowercase member *values* — mirrors the RunModeEnum/ConfigTag pattern. +_ITERATION_STATUS_PG_ENUM = ENUM( + EvaluationIterationStatusEnum, + name="evaluationiterationstatusenum", + values_callable=lambda enum_cls: [member.value for member in enum_cls], + create_type=False, +) + + +class EvaluationIterationRun(SQLModel, table=True): + """Thin per-loop tracking row for the eval-iterate-improve LangGraph loop. + + `id` doubles as the LangGraph `thread_id` (`str(id)`) — no separate + thread_id column needed, since a loop and its checkpoint thread are 1:1. + """ + + __tablename__ = "evaluation_iteration_run" + + id: int = Field( + default=None, + primary_key=True, + sa_column_kwargs={ + "comment": "Unique identifier; str(id) is the LangGraph thread_id" + }, + ) + dataset_id: int = Field( + foreign_key="evaluation_dataset.id", + nullable=False, + index=True, + ondelete="CASCADE", + sa_column_kwargs={ + "comment": "Reference to the evaluation dataset iterated against" + }, + ) + experiment_name: str = Field( + max_length=255, + description="Base name for derived per-round eval run names", + sa_column_kwargs={ + "comment": "Base name; each round's eval run is named f'{experiment_name}-iter{id}-r{round_number}'" + }, + ) + config_id: UUID = Field( + foreign_key="config.id", + nullable=False, + index=True, + ondelete="RESTRICT", + sa_column_kwargs={ + "comment": "Reference to the stored config being iterated on" + }, + ) + initial_config_version: int = Field( + ge=1, + description="Config version supplied at kickoff", + sa_column_kwargs={ + "comment": "Config version supplied at kickoff, recorded for the report" + }, + ) + status: EvaluationIterationStatusEnum = Field( + default=EvaluationIterationStatusEnum.PROCESSING, + sa_column=Column( + _ITERATION_STATUS_PG_ENUM, + nullable=False, + comment="Loop bookkeeping status: processing, completed, or failed", + ), + ) + stop_reason: str | None = Field( + default=None, + sa_column_kwargs={ + "comment": "Copied from the final graph state once terminal: ceiling_reached, max_rounds_reached, or round_failed" + }, + ) + callback_url: str = Field( + sa_column_kwargs={ + "comment": "HTTPS webhook validated via validate_callback_url before create" + }, + ) + error_message: str | None = Field( + default=None, + sa_column_kwargs={"comment": "Error detail if the loop failed"}, + ) + organization_id: int = Field( + foreign_key="organization.id", + nullable=False, + index=True, + ondelete="CASCADE", + sa_column_kwargs={"comment": "Reference to the organization"}, + ) + project_id: int = Field( + foreign_key="project.id", + nullable=False, + index=True, + ondelete="CASCADE", + sa_column_kwargs={"comment": "Reference to the project"}, + ) + inserted_at: datetime = Field( + default_factory=now, + nullable=False, + sa_column_kwargs={"comment": "Timestamp when the iteration loop was created"}, + ) + updated_at: datetime = Field( + default_factory=now, + nullable=False, + sa_column_kwargs={ + "comment": "Timestamp when the iteration loop was last updated" + }, + ) + + +class EvaluationIterationRunUpdate(SQLModel): + """Partial update payload, `exclude_unset` semantics.""" + + status: EvaluationIterationStatusEnum | None = None + stop_reason: str | None = None + error_message: str | None = None + + +class EvaluationIterationCreateRequest(SQLModel): + """Body for POST /api/v2/evaluations/iterations.""" + + dataset_id: int = Field(description="ID of the evaluation dataset") + experiment_name: str = Field( + min_length=3, description="Base name for this iteration loop's per-round runs" + ) + config_id: UUID = Field(description="Stored config ID to start iterating from") + config_version: int = Field(ge=1, description="Starting stored config version") + max_rounds: int | None = Field( + default=None, + ge=1, + description=( + "Safety cap on rounds; defaults to EVAL_ITERATION_MAX_ROUNDS_DEFAULT, " + "capped at EVAL_ITERATION_MAX_ROUNDS_HARD_CAP" + ), + ) + callback_url: HttpUrl = Field( + description="HTTPS webhook that receives the round-by-round report once the loop stops." + ) + + +class EvaluationIterationRunImmediatePublic(SQLModel): + """202 response body: the loop was created and the first round dispatched.""" + + iteration_run_id: int + status: EvaluationIterationStatusEnum + message: str + inserted_at: datetime + updated_at: datetime + + +class EvaluationIterationRoundPublic(SQLModel): + """One round's outcome — mirrors a `history` entry in the graph checkpoint state.""" + + round_number: int + eval_run_id: int + config_version: int + stop_score: float + kb_score: float | None = Field( + default=None, + description="Adherence to Knowledge Base, recorded for visibility only — never gates the stop condition", + ) + + +class EvaluationIterationReportPublic(SQLModel): + """Callback payload POSTed to `callback_url` once the loop reaches a terminal state.""" + + iteration_run_id: int + status: EvaluationIterationStatusEnum + stop_reason: str | None + best_round: EvaluationIterationRoundPublic | None + history: list[EvaluationIterationRoundPublic] + error_message: str | None = None diff --git a/backend/app/services/evaluations/fast.py b/backend/app/services/evaluations/fast.py index 20b0e94c6..2817863d4 100644 --- a/backend/app/services/evaluations/fast.py +++ b/backend/app/services/evaluations/fast.py @@ -136,44 +136,27 @@ def _load_items_from_object_store( return items -def validate_and_start_fast_evaluation( +def validate_fast_evaluation_inputs( *, session: Session, dataset_id: int, - run_name: str, config_id: UUID, config_version: int, organization_id: int, project_id: int, - trace_id: str = "N/A", is_judge_run: bool = False, -) -> EvaluationRun: - """Validate + create + dispatch a fast evaluation run. +) -> EvaluationDataset: + """Run the dataset/config precondition checks shared by + `validate_and_start_fast_evaluation` and the eval-iteration graph's + `start_eval_node`. Raises HTTPException on the first failing check; returns + the validated dataset on success. No DB writes. - Validation (in order): + Checks (in order): 1. Dataset exists; v1 runs also require a Langfuse id, v2 judged runs don't (they load items from S3). 2. Config resolves to a text-type OpenAI config. 3. Dataset's original_items_count <= EVAL_FAST_MAX_UNIQUE_ROWS. - 4. (organization_id, project_id, run_name) is unique — enforced by the DB - constraint; a collision is translated to 409 by the shared helper. - - On success the function creates the EvaluationRun row with - `run_mode="fast"`, `status="processing"`, and enqueues the orchestrator - task. The caller (route) returns the row immediately. - - `is_judge_run` is the v2 native-judge marker, persisted on the run before - dispatch so the aggregate (which only knows eval_run_id) reads it at judge - time. It defaults to the v1 behavior — no judging, Langfuse sync as today — - so the v1 call path is unchanged. Judging is system-config only: the judge - always uses the fallback model + built-in prompt, so there is no per-run config. """ - logger.info( - f"[validate_and_start_fast_evaluation] Starting fast eval | " - f"run_name={run_name} | dataset_id={dataset_id} | " - f"org_id={organization_id} | project_id={project_id}" - ) - # 1. Dataset must exist (Langfuse id required for v1 runs only; see below). dataset = get_dataset_by_id( session=session, @@ -244,7 +227,54 @@ def validate_and_start_fast_evaluation( ), ) - # 4. Create the run; the shared helper translates a duplicate run_name into 409. + return dataset + + +def validate_and_start_fast_evaluation( + *, + session: Session, + dataset_id: int, + run_name: str, + config_id: UUID, + config_version: int, + organization_id: int, + project_id: int, + trace_id: str = "N/A", + is_judge_run: bool = False, +) -> EvaluationRun: + """Validate + create + dispatch a fast evaluation run. + + Validation is `validate_fast_evaluation_inputs` (dataset/config checks); on + top of that, (organization_id, project_id, run_name) must be unique — enforced + by the DB constraint, a collision is translated to 409 by the shared helper. + + On success the function creates the EvaluationRun row with + `run_mode="fast"`, `status="processing"`, and enqueues the orchestrator + task. The caller (route) returns the row immediately. + + `is_judge_run` is the v2 native-judge marker, persisted on the run before + dispatch so the aggregate (which only knows eval_run_id) reads it at judge + time. It defaults to the v1 behavior — no judging, Langfuse sync as today — + so the v1 call path is unchanged. Judging is system-config only: the judge + always uses the fallback model + built-in prompt, so there is no per-run config. + """ + logger.info( + f"[validate_and_start_fast_evaluation] Starting fast eval | " + f"run_name={run_name} | dataset_id={dataset_id} | " + f"org_id={organization_id} | project_id={project_id}" + ) + + dataset = validate_fast_evaluation_inputs( + session=session, + dataset_id=dataset_id, + config_id=config_id, + config_version=config_version, + organization_id=organization_id, + project_id=project_id, + is_judge_run=is_judge_run, + ) + + # Create the run; the shared helper translates a duplicate run_name into 409. eval_run = create_evaluation_run_or_409( session=session, run_name=run_name, diff --git a/backend/app/services/evaluations/iteration.py b/backend/app/services/evaluations/iteration.py new file mode 100644 index 000000000..0a7a1e1fa --- /dev/null +++ b/backend/app/services/evaluations/iteration.py @@ -0,0 +1,161 @@ +"""Kickoff + scoring for the eval-iterate-improve LangGraph loop. + +The loop itself (`StateGraph`, nodes, checkpoint/resume) lives in +`iteration_graph.py`. This module is the request-side entry point +(`validate_and_start_evaluation_iteration`, called from the route) and the one +scoring helper (`compute_round_scores`) shared by `wait_eval_node`. +""" + +import logging +from statistics import mean +from uuid import UUID + +from fastapi import HTTPException +from sqlmodel import Session + +from app.celery.utils import start_evaluation_iteration_round +from app.core.config import settings +from app.crud.evaluations.iteration import ( + create_evaluation_iteration_run, + update_evaluation_iteration_run, +) +from app.crud.evaluations.score import ( + GROUND_TRUTH_SCORE_NAME, + KNOWLEDGE_BASE_SCORE_NAME, + PROMPT_SCORE_NAME, +) +from app.models.evaluation import EvaluationRun +from app.models.evaluation_iteration import ( + EvaluationIterationRun, + EvaluationIterationRunUpdate, + EvaluationIterationStatusEnum, +) +from app.services.evaluations.fast import validate_fast_evaluation_inputs + +logger = logging.getLogger(__name__) + +# `EvaluationIterationRun.stop_reason` / `EvaluationIterationReportPublic.stop_reason` +# values, set by the graph's `wait_eval_node` / `wait_improve_node`. +STOP_REASON_CEILING_REACHED = "ceiling_reached" +STOP_REASON_MAX_ROUNDS_REACHED = "max_rounds_reached" +STOP_REASON_ROUND_FAILED = "round_failed" + + +def validate_and_start_evaluation_iteration( + *, + session: Session, + dataset_id: int, + experiment_name: str, + config_id: UUID, + config_version: int, + max_rounds: int | None, + callback_url: str, + organization_id: int, + project_id: int, + trace_id: str = "N/A", +) -> EvaluationIterationRun: + """Validate preconditions, create the thin tracking row, and dispatch round 1. + + Reuses `validate_fast_evaluation_inputs` (dataset/config checks) unchanged — + always `is_judge_run=True` since the loop only ever runs judged (v2) evals. + `max_rounds` is clamped to `EVAL_ITERATION_MAX_ROUNDS_HARD_CAP`, not rejected, + so a caller passing an oversized value degrades to the safety cap instead of + failing outright. + """ + resolved_max_rounds = min( + max_rounds or settings.EVAL_ITERATION_MAX_ROUNDS_DEFAULT, + settings.EVAL_ITERATION_MAX_ROUNDS_HARD_CAP, + ) + + logger.info( + f"[validate_and_start_evaluation_iteration] Starting iteration loop | " + f"dataset_id={dataset_id} | experiment_name={experiment_name} | " + f"max_rounds={resolved_max_rounds} | org_id={organization_id} | " + f"project_id={project_id}" + ) + + validate_fast_evaluation_inputs( + session=session, + dataset_id=dataset_id, + config_id=config_id, + config_version=config_version, + organization_id=organization_id, + project_id=project_id, + is_judge_run=True, + ) + + iteration_run = create_evaluation_iteration_run( + session=session, + dataset_id=dataset_id, + experiment_name=experiment_name, + config_id=config_id, + initial_config_version=config_version, + callback_url=callback_url, + organization_id=organization_id, + project_id=project_id, + ) + + try: + task_id = start_evaluation_iteration_round( + iteration_run_id=iteration_run.id, + resume=False, + organization_id=organization_id, + project_id=project_id, + max_rounds=resolved_max_rounds, + config_version=config_version, + trace_id=trace_id, + ) + except Exception as exc: + logger.error( + f"[validate_and_start_evaluation_iteration] Failed to enqueue | " + f"iteration_run_id={iteration_run.id} | error={exc}", + exc_info=True, + ) + update_evaluation_iteration_run( + session=session, + iteration_run=iteration_run, + update=EvaluationIterationRunUpdate( + status=EvaluationIterationStatusEnum.FAILED, + error_message=f"Failed to queue evaluation iteration: {exc}", + ), + ) + raise HTTPException( + status_code=500, + detail="evaluation_iteration_enqueue_failed: could not queue the loop", + ) + + logger.info( + f"[validate_and_start_evaluation_iteration] Enqueued | " + f"iteration_run_id={iteration_run.id} | task_id={task_id}" + ) + return iteration_run + + +def compute_round_scores(eval_run: EvaluationRun) -> tuple[float, float | None] | None: + """Derive a round's (stop_score, kb_score) from a completed judged run. + + stop_score = mean(Adherence to Ground Truth, Adherence to Prompt) — the only + metrics that gate the stop condition. kb_score (Adherence to Knowledge Base) + is returned for visibility only; `None` when absent since it never gates. + + Returns `None` when either required metric is missing from summary_scores — + the caller (`wait_eval_node`) treats that the same as a failed round. + """ + summary_scores = (eval_run.score or {}).get("summary_scores", []) + scores_by_name = {s["name"]: s for s in summary_scores} + + ground_truth = scores_by_name.get(GROUND_TRUTH_SCORE_NAME) + prompt = scores_by_name.get(PROMPT_SCORE_NAME) + if ground_truth is None or prompt is None: + logger.warning( + f"[compute_round_scores] Missing required metric | " + f"eval_run_id={eval_run.id} | " + f"has_ground_truth={ground_truth is not None} | " + f"has_prompt={prompt is not None}" + ) + return None + + stop_score = mean([ground_truth["avg"], prompt["avg"]]) + knowledge_base = scores_by_name.get(KNOWLEDGE_BASE_SCORE_NAME) + kb_score = knowledge_base["avg"] if knowledge_base is not None else None + return stop_score, kb_score diff --git a/backend/app/services/evaluations/iteration_graph.py b/backend/app/services/evaluations/iteration_graph.py new file mode 100644 index 000000000..8f2872edb --- /dev/null +++ b/backend/app/services/evaluations/iteration_graph.py @@ -0,0 +1,594 @@ +"""LangGraph orchestration for the eval-iterate-improve loop. + +`StateGraph` cycle: start_eval -> wait_eval -> (conditional) -> {finalize, start_improve} +start_improve -> wait_improve -> start_eval (loop-back). + +Cyclic-graph + checkpoint/resume are the only reason LangGraph is used here — the +loop's own stop/continue decisions are deterministic (delta-threshold, round cap), +not LLM-planned. The only LLM call anywhere in the loop is the existing +prompt-drafting call inside `execute_prompt_improvement`, invoked unchanged via +`start_prompt_improvement_job`. + +Rule for every node: open and close its own short-lived DB session inside the node +function. A pause at `interrupt()` can span many cron ticks (hours), so nothing +DB-related may survive between calls except what's persisted in the checkpoint +(LangGraph-owned) or the thin `EvaluationIterationRun` row. +""" + +import logging +from functools import lru_cache +from typing import Any, TypedDict +from uuid import UUID + +from celery.exceptions import SoftTimeLimitExceeded +from langgraph.checkpoint.postgres import PostgresSaver +from langgraph.graph import END, START, StateGraph +from langgraph.graph.state import CompiledStateGraph +from langgraph.types import Command, interrupt +from psycopg.rows import dict_row +from psycopg_pool import ConnectionPool +from sqlmodel import Session + +from app.core.config import settings +from app.core.db import engine +from app.crud.evaluations.core import TERMINAL_EVAL_STATUSES, get_evaluation_run_by_id +from app.crud.evaluations.iteration import ( + get_evaluation_iteration_run_by_id, + update_evaluation_iteration_run, +) +from app.crud.jobs import JobCrud +from app.models.evaluation_iteration import ( + EvaluationIterationReportPublic, + EvaluationIterationRoundPublic, + EvaluationIterationRunUpdate, + EvaluationIterationStatusEnum, +) +from app.models.job import JobStatus +from app.services.evaluations.fast import validate_and_start_fast_evaluation +from app.services.evaluations.iteration import ( + STOP_REASON_CEILING_REACHED, + STOP_REASON_MAX_ROUNDS_REACHED, + STOP_REASON_ROUND_FAILED, + compute_round_scores, +) +from app.services.evaluations.prompt_improvement import start_prompt_improvement_job +from app.utils import APIResponse, get_webhook_secret, send_callback + +logger = logging.getLogger(__name__) + +_JOB_WAITING_STATUSES = {JobStatus.PENDING, JobStatus.PROCESSING} + + +class EvaluationIterationState(TypedDict): + iteration_run_id: int + dataset_id: int + experiment_name: str + config_id: str + config_version: int + round_number: int + max_rounds: int + current_eval_run_id: int | None + current_improvement_job_id: str | None + history: list[dict[str, Any]] + best_round_number: int | None + best_config_version: int | None + best_stop_score: float | None + consecutive_low_delta_rounds: int + stop_reason: str | None + error_message: str | None + organization_id: int + project_id: int + callback_url: str + + +def _psycopg_conn_string() -> str: + """Derive a plain psycopg conninfo string from the app's SQLAlchemy DSN. + + langgraph-checkpoint-postgres connects via psycopg (v3) directly rather than + through the SQLAlchemy engine, but the app's DSN already targets the psycopg + driver (`postgresql+psycopg://`) — stripping the SQLAlchemy dialect qualifier + is the only adaptation needed. + """ + return str(settings.SQLALCHEMY_DATABASE_URI).replace( + "postgresql+psycopg://", "postgresql://", 1 + ) + + +@lru_cache(maxsize=1) +def get_evaluation_iteration_checkpointer() -> PostgresSaver: + """Module-level singleton checkpointer, backed by its own small connection pool. + + `.setup()` creates the checkpoint tables (`checkpoints`, `checkpoint_blobs`, + `checkpoint_writes`) — schema owned by the library, not Alembic. It's a + `CREATE TABLE IF NOT EXISTS`-style call, so it's safe to run on every first + access rather than gating it behind a separate startup hook. + """ + pool = ConnectionPool( + conninfo=_psycopg_conn_string(), + min_size=1, + max_size=5, + open=True, + kwargs={"autocommit": True, "prepare_threshold": 0, "row_factory": dict_row}, + ) + checkpointer = PostgresSaver(pool) + checkpointer.setup() + logger.info("[get_evaluation_iteration_checkpointer] Checkpointer ready") + return checkpointer + + +def start_eval_node(state: EvaluationIterationState) -> dict[str, Any]: + """Kick off this round's judged fast-eval run.""" + run_name = ( + f"{state['experiment_name']}-iter{state['iteration_run_id']}-" + f"r{state['round_number']}" + ) + with Session(engine) as session: + eval_run = validate_and_start_fast_evaluation( + session=session, + dataset_id=state["dataset_id"], + run_name=run_name, + config_id=UUID(state["config_id"]), + config_version=state["config_version"], + organization_id=state["organization_id"], + project_id=state["project_id"], + is_judge_run=True, + ) + logger.info( + f"[start_eval_node] Round eval started | " + f"iteration_run_id={state['iteration_run_id']} | " + f"round_number={state['round_number']} | eval_run_id={eval_run.id}" + ) + return {"current_eval_run_id": eval_run.id} + + +def wait_eval_node(state: EvaluationIterationState) -> dict[str, Any]: + """Poll the round's eval run; score + decide continue/stop once it's terminal. + + A `Command(resume=True)` replays this node from the top, and `interrupt()` only + re-pauses for a call position that has no queued resume yet — so a single + interrupt-then-return falls straight through on every resume after the first. + Looping the fetch+check+interrupt forces a fresh (unconsumed) interrupt() call + each tick the run is still processing, so it correctly re-pauses instead of + proceeding as if the run were done. + """ + current_eval_run_id = state["current_eval_run_id"] + if current_eval_run_id is None: + # Invariant: start_eval_node always sets this before wait_eval_node runs. + return { + "stop_reason": STOP_REASON_ROUND_FAILED, + "error_message": "wait_eval_node reached with no current_eval_run_id set", + } + + with Session(engine) as session: + while True: + eval_run = get_evaluation_run_by_id( + session=session, + evaluation_id=current_eval_run_id, + organization_id=state["organization_id"], + project_id=state["project_id"], + ) + if eval_run is None: + return { + "stop_reason": STOP_REASON_ROUND_FAILED, + "error_message": f"EvaluationRun {current_eval_run_id} not found", + } + if eval_run.status in TERMINAL_EVAL_STATUSES: + break + interrupt({"waiting_on": "eval", "eval_run_id": eval_run.id}) + + if eval_run.status == "failed": + return { + "stop_reason": STOP_REASON_ROUND_FAILED, + "error_message": eval_run.error_message or "Evaluation run failed", + } + + scores = compute_round_scores(eval_run) + if scores is None: + return { + "stop_reason": STOP_REASON_ROUND_FAILED, + "error_message": ( + "Missing required judge metric (Adherence to Ground Truth / " + "Adherence to Prompt) in eval_run.score.summary_scores" + ), + } + eval_run_id = eval_run.id + + stop_score, kb_score = scores + round_entry = { + "round_number": state["round_number"], + "eval_run_id": eval_run_id, + "config_version": state["config_version"], + "stop_score": stop_score, + "kb_score": kb_score, + } + history = [*state["history"], round_entry] + + best_stop_score = state.get("best_stop_score") + best_round_number = state.get("best_round_number") + best_config_version = state.get("best_config_version") + if best_stop_score is None or stop_score > best_stop_score: + best_stop_score = stop_score + best_round_number = state["round_number"] + best_config_version = state["config_version"] + + previous_scores = [entry["stop_score"] for entry in state["history"]] + consecutive_low_delta_rounds = 0 + if previous_scores: + delta = stop_score - previous_scores[-1] + if delta < settings.EVAL_ITERATION_CEILING_DELTA_THRESHOLD: + consecutive_low_delta_rounds = ( + state.get("consecutive_low_delta_rounds", 0) + 1 + ) + + update: dict[str, Any] = { + "history": history, + "best_stop_score": best_stop_score, + "best_round_number": best_round_number, + "best_config_version": best_config_version, + "consecutive_low_delta_rounds": consecutive_low_delta_rounds, + } + if ( + consecutive_low_delta_rounds + >= settings.EVAL_ITERATION_CEILING_CONSECUTIVE_ROUNDS + ): + update["stop_reason"] = STOP_REASON_CEILING_REACHED + elif state["round_number"] >= state["max_rounds"]: + update["stop_reason"] = STOP_REASON_MAX_ROUNDS_REACHED + + logger.info( + f"[wait_eval_node] Round scored | iteration_run_id={state['iteration_run_id']} | " + f"round_number={state['round_number']} | stop_score={stop_score} | " + f"consecutive_low_delta_rounds={consecutive_low_delta_rounds} | " + f"stop_reason={update.get('stop_reason')}" + ) + return update + + +def route_after_eval(state: EvaluationIterationState) -> str: + return "finalize_node" if state.get("stop_reason") else "start_improve_node" + + +def start_improve_node(state: EvaluationIterationState) -> dict[str, Any]: + """Draft the next prompt version from this round's judge traces. + + `callback_url=""` is a deliberate no-op — `_send_improve_prompt_callback` + already skips the HTTP round-trip on an empty URL, since only the loop's own + `finalize_node` callback is user-facing. + """ + current_eval_run_id = state["current_eval_run_id"] + if current_eval_run_id is None: + # Invariant: only reached via route_after_eval, after wait_eval_node set this. + raise ValueError("start_improve_node reached with no current_eval_run_id set") + + with Session(engine) as session: + job = start_prompt_improvement_job( + session=session, + evaluation_id=current_eval_run_id, + organization_id=state["organization_id"], + project_id=state["project_id"], + callback_url="", + require_judge_run=True, + ) + logger.info( + f"[start_improve_node] Prompt improvement job started | " + f"iteration_run_id={state['iteration_run_id']} | job_id={job.id}" + ) + return {"current_improvement_job_id": str(job.id)} + + +def wait_improve_node(state: EvaluationIterationState) -> dict[str, Any]: + """Poll the prompt-improvement job; advance the round once it's terminal. + + Loops the fetch+check+interrupt (see `wait_eval_node` docstring for why): a + resume replays the node from the top, and a single interrupt-then-return would + fall through on every resume after the first instead of re-pausing. + """ + current_improvement_job_id = state["current_improvement_job_id"] + if current_improvement_job_id is None: + # Invariant: start_improve_node always sets this before wait_improve_node runs. + raise ValueError( + "wait_improve_node reached with no current_improvement_job_id set" + ) + + with Session(engine) as session: + while True: + job = JobCrud(session=session).get( + job_id=UUID(current_improvement_job_id), + project_id=state["project_id"], + ) + if job is None: + return { + "stop_reason": STOP_REASON_ROUND_FAILED, + "error_message": ( + f"Prompt improvement job {current_improvement_job_id} not found" + ), + } + if job.status not in _JOB_WAITING_STATUSES: + break + interrupt({"waiting_on": "improve", "job_id": str(job.id)}) + + if job.status == JobStatus.FAILED: + return { + "stop_reason": STOP_REASON_ROUND_FAILED, + "error_message": job.error_message or "Prompt improvement job failed", + } + + new_version = (job.meta or {}).get("version") + + if new_version is None: + return { + "stop_reason": STOP_REASON_ROUND_FAILED, + "error_message": "Prompt improvement job succeeded without a version in meta", + } + + logger.info( + f"[wait_improve_node] Prompt improved | " + f"iteration_run_id={state['iteration_run_id']} | " + f"next_round_number={state['round_number'] + 1} | config_version={new_version}" + ) + return { + "round_number": state["round_number"] + 1, + "config_version": new_version, + "current_improvement_job_id": None, + } + + +def _build_iteration_report( + state: EvaluationIterationState, status: EvaluationIterationStatusEnum +) -> EvaluationIterationReportPublic: + history = [EvaluationIterationRoundPublic(**entry) for entry in state["history"]] + best_round = next( + (r for r in history if r.round_number == state.get("best_round_number")), + None, + ) + return EvaluationIterationReportPublic( + iteration_run_id=state["iteration_run_id"], + status=status, + stop_reason=state.get("stop_reason"), + best_round=best_round, + history=history, + error_message=state.get("error_message"), + ) + + +def finalize_node(state: EvaluationIterationState) -> dict[str, Any]: + """Terminal node: persist the thin row and POST the report to callback_url.""" + stop_reason = state.get("stop_reason") + if stop_reason is None: + # Invariant: route_after_eval only reaches this node once stop_reason is set. + logger.warning( + f"[finalize_node] Reached with no stop_reason set | " + f"iteration_run_id={state['iteration_run_id']}" + ) + stop_reason = STOP_REASON_MAX_ROUNDS_REACHED + status = ( + EvaluationIterationStatusEnum.FAILED + if stop_reason == STOP_REASON_ROUND_FAILED + else EvaluationIterationStatusEnum.COMPLETED + ) + + with Session(engine) as session: + iteration_run = get_evaluation_iteration_run_by_id( + session=session, + iteration_run_id=state["iteration_run_id"], + organization_id=state["organization_id"], + project_id=state["project_id"], + ) + if iteration_run is None: + logger.error( + f"[finalize_node] EvaluationIterationRun not found | " + f"iteration_run_id={state['iteration_run_id']}" + ) + return {} + + update_evaluation_iteration_run( + session=session, + iteration_run=iteration_run, + update=EvaluationIterationRunUpdate( + status=status, + stop_reason=stop_reason, + error_message=state.get("error_message"), + ), + ) + + report = _build_iteration_report(state, status) + error_message = state.get("error_message") + envelope = ( + APIResponse.failure_response( + error=error_message, data=report.model_dump(mode="json") + ) + if error_message + else APIResponse.success_response(data=report.model_dump(mode="json")) + ) + webhook_secret = get_webhook_secret(state["project_id"], state["organization_id"]) + send_callback( + state["callback_url"], envelope.model_dump(), webhook_secret=webhook_secret + ) + + logger.info( + f"[finalize_node] Loop finished | iteration_run_id={state['iteration_run_id']} | " + f"status={status.value} | stop_reason={stop_reason} | " + f"rounds={len(state['history'])}" + ) + return {} + + +def build_evaluation_iteration_graph(checkpointer: PostgresSaver) -> CompiledStateGraph: + graph = StateGraph(EvaluationIterationState) + graph.add_node("start_eval_node", start_eval_node) + graph.add_node("wait_eval_node", wait_eval_node) + graph.add_node("start_improve_node", start_improve_node) + graph.add_node("wait_improve_node", wait_improve_node) + graph.add_node("finalize_node", finalize_node) + + graph.add_edge(START, "start_eval_node") + graph.add_edge("start_eval_node", "wait_eval_node") + graph.add_conditional_edges( + "wait_eval_node", + route_after_eval, + {"finalize_node": "finalize_node", "start_improve_node": "start_improve_node"}, + ) + graph.add_edge("start_improve_node", "wait_improve_node") + graph.add_edge("wait_improve_node", "start_eval_node") + graph.add_edge("finalize_node", END) + + return graph.compile(checkpointer=checkpointer) + + +def _build_initial_state( + *, + iteration_run_id: int, + organization_id: int, + project_id: int, + max_rounds: int | None, + config_version: int | None, +) -> EvaluationIterationState: + with Session(engine) as session: + iteration_run = get_evaluation_iteration_run_by_id( + session=session, + iteration_run_id=iteration_run_id, + organization_id=organization_id, + project_id=project_id, + ) + if iteration_run is None: + raise ValueError(f"EvaluationIterationRun {iteration_run_id} not found") + + return EvaluationIterationState( + iteration_run_id=iteration_run.id, + dataset_id=iteration_run.dataset_id, + experiment_name=iteration_run.experiment_name, + config_id=str(iteration_run.config_id), + config_version=config_version or iteration_run.initial_config_version, + round_number=1, + max_rounds=max_rounds or settings.EVAL_ITERATION_MAX_ROUNDS_DEFAULT, + current_eval_run_id=None, + current_improvement_job_id=None, + history=[], + best_round_number=None, + best_config_version=None, + best_stop_score=None, + consecutive_low_delta_rounds=0, + stop_reason=None, + error_message=None, + organization_id=iteration_run.organization_id, + project_id=iteration_run.project_id, + callback_url=iteration_run.callback_url, + ) + + +def _mark_iteration_run_failed( + *, iteration_run_id: int, organization_id: int, project_id: int, error_message: str +) -> None: + """Fail a loop from a fresh session so a killed task leaves no dangling row.""" + try: + with Session(engine) as session: + iteration_run = get_evaluation_iteration_run_by_id( + session=session, + iteration_run_id=iteration_run_id, + organization_id=organization_id, + project_id=project_id, + ) + if ( + iteration_run is None + or iteration_run.status != EvaluationIterationStatusEnum.PROCESSING + ): + return + update_evaluation_iteration_run( + session=session, + iteration_run=iteration_run, + update=EvaluationIterationRunUpdate( + status=EvaluationIterationStatusEnum.FAILED, + error_message=error_message, + ), + ) + logger.info( + f"[_mark_iteration_run_failed] iteration_run_id={iteration_run_id} marked failed" + ) + except Exception: + logger.error( + f"[_mark_iteration_run_failed] Could not mark iteration_run_id=" + f"{iteration_run_id} failed", + exc_info=True, + ) + + +def _run_graph_step( + *, + iteration_run_id: int, + resume: bool, + organization_id: int, + project_id: int, + max_rounds: int | None, + config_version: int | None, +) -> None: + checkpointer = get_evaluation_iteration_checkpointer() + graph = build_evaluation_iteration_graph(checkpointer) + thread_config = {"configurable": {"thread_id": str(iteration_run_id)}} + + if resume: + graph.invoke(Command(resume=True), config=thread_config) + return + + initial_state = _build_initial_state( + iteration_run_id=iteration_run_id, + organization_id=organization_id, + project_id=project_id, + max_rounds=max_rounds, + config_version=config_version, + ) + graph.invoke(initial_state, config=thread_config) + + +def execute_evaluation_iteration_graph_step( + *, + iteration_run_id: int, + resume: bool, + organization_id: int, + project_id: int, + max_rounds: int | None = None, + config_version: int | None = None, +) -> None: + """Guarded entrypoint: advance one graph step, never leave the thin row dangling. + + Either re-interrupts (checkpoint already persisted by LangGraph, thin row stays + PROCESSING) or reaches `finalize_node` (which already updated the thin row and + sent the callback before this returns). + """ + logger.info( + f"[execute_evaluation_iteration_graph_step] Starting | " + f"iteration_run_id={iteration_run_id} | resume={resume}" + ) + try: + _run_graph_step( + iteration_run_id=iteration_run_id, + resume=resume, + organization_id=organization_id, + project_id=project_id, + max_rounds=max_rounds, + config_version=config_version, + ) + except SoftTimeLimitExceeded: + logger.error( + f"[execute_evaluation_iteration_graph_step] Soft time limit | " + f"iteration_run_id={iteration_run_id}" + ) + _mark_iteration_run_failed( + iteration_run_id=iteration_run_id, + organization_id=organization_id, + project_id=project_id, + error_message="Evaluation iteration step exceeded the time limit.", + ) + raise + except Exception: + logger.error( + f"[execute_evaluation_iteration_graph_step] Unexpected failure | " + f"iteration_run_id={iteration_run_id}", + exc_info=True, + ) + _mark_iteration_run_failed( + iteration_run_id=iteration_run_id, + organization_id=organization_id, + project_id=project_id, + error_message="Evaluation iteration loop failed unexpectedly.", + ) + raise diff --git a/backend/app/tests/api/routes/test_evaluation_iteration_v2.py b/backend/app/tests/api/routes/test_evaluation_iteration_v2.py new file mode 100644 index 000000000..13f3eacf2 --- /dev/null +++ b/backend/app/tests/api/routes/test_evaluation_iteration_v2.py @@ -0,0 +1,152 @@ +"""Tests for `POST /api/v2/evaluations/iterations` — the eval-iterate-improve +loop trigger. The Celery enqueue (`start_evaluation_iteration_round`) and the +SSRF-checking `validate_callback_url` (real DNS resolution) are the mocked +boundaries; the DB is real. +""" + +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app.core.config import settings +from app.models import Config, EvaluationDataset +from app.models.evaluation_iteration import EvaluationIterationRun +from app.models.llm.request import ConfigBlob, KaapiCompletionConfig +from app.tests.utils.auth import TestAuthContext +from app.tests.utils.test_data import ( + create_test_config, + create_test_evaluation_dataset, +) + +ITERATIONS_URL = f"{settings.API_V2_STR}/evaluations/iterations" +_ROUTE_VALIDATE = "app.api.routes.evaluations.iteration_v2.validate_callback_url" + + +def _make_dataset(*, db: Session, user_api_key: TestAuthContext) -> EvaluationDataset: + return create_test_evaluation_dataset( + db=db, + organization_id=user_api_key.organization_id, + project_id=user_api_key.project_id, + ) + + +def _make_text_config(db: Session, project_id: int) -> Config: + blob = ConfigBlob( + completion=KaapiCompletionConfig( + provider="openai", + type="text", + params={"model": "gpt-4o-iter-route-test", "temperature": 0.7}, + ) + ) + return create_test_config( + db=db, project_id=project_id, use_kaapi_schema=True, config_blob=blob + ) + + +@pytest.fixture +def _patch_dispatch(): + """Stub the Celery enqueue and skip the real-DNS SSRF check for example.com.""" + with ( + patch(_ROUTE_VALIDATE), + patch( + "app.services.evaluations.iteration.start_evaluation_iteration_round", + return_value="fake-task-id", + ) as mock_start, + ): + yield mock_start + + +class TestCreateEvaluationIterationRoute: + def test_valid_request_returns_202_and_expected_shape( + self, + client: TestClient, + user_api_key_header: dict[str, str], + db: Session, + user_api_key: TestAuthContext, + _patch_dispatch, + ) -> None: + dataset = _make_dataset(db=db, user_api_key=user_api_key) + config = _make_text_config(db, user_api_key.project_id) + + resp = client.post( + ITERATIONS_URL, + json={ + "dataset_id": dataset.id, + "experiment_name": "route-iter", + "config_id": str(config.id), + "config_version": 1, + "callback_url": "https://example.com/callback", + }, + headers=user_api_key_header, + ) + + assert resp.status_code == 202, resp.text + body = resp.json()["data"] + assert body["status"] == "processing" + assert "iteration_run_id" in body + assert "inserted_at" in body and "updated_at" in body + + run = db.get(EvaluationIterationRun, body["iteration_run_id"]) + assert run is not None + assert run.experiment_name == "route-iter" + assert run.dataset_id == dataset.id + assert run.config_id == config.id + _patch_dispatch.assert_called_once() + + def test_invalid_callback_url_returns_422( + self, + client: TestClient, + user_api_key_header: dict[str, str], + db: Session, + user_api_key: TestAuthContext, + _patch_dispatch, + ) -> None: + dataset = _make_dataset(db=db, user_api_key=user_api_key) + config = _make_text_config(db, user_api_key.project_id) + + resp = client.post( + ITERATIONS_URL, + json={ + "dataset_id": dataset.id, + "experiment_name": "route-bad-cb", + "config_id": str(config.id), + "config_version": 1, + "callback_url": "not-a-valid-url", + }, + headers=user_api_key_header, + ) + + assert resp.status_code == 422 + _patch_dispatch.assert_not_called() + + def test_max_rounds_above_hard_cap_is_clamped_not_rejected( + self, + client: TestClient, + user_api_key_header: dict[str, str], + db: Session, + user_api_key: TestAuthContext, + _patch_dispatch, + ) -> None: + dataset = _make_dataset(db=db, user_api_key=user_api_key) + config = _make_text_config(db, user_api_key.project_id) + + resp = client.post( + ITERATIONS_URL, + json={ + "dataset_id": dataset.id, + "experiment_name": "route-clamp", + "config_id": str(config.id), + "config_version": 1, + "max_rounds": settings.EVAL_ITERATION_MAX_ROUNDS_HARD_CAP + 100, + "callback_url": "https://example.com/callback", + }, + headers=user_api_key_header, + ) + + assert resp.status_code == 202, resp.text + assert ( + _patch_dispatch.call_args.kwargs["max_rounds"] + == settings.EVAL_ITERATION_MAX_ROUNDS_HARD_CAP + ) diff --git a/backend/app/tests/crud/evaluations/test_cron_iteration.py b/backend/app/tests/crud/evaluations/test_cron_iteration.py new file mode 100644 index 000000000..928da4f04 --- /dev/null +++ b/backend/app/tests/crud/evaluations/test_cron_iteration.py @@ -0,0 +1,101 @@ +"""Tests for the eval-iteration cron resume dispatcher. + +`dispatch_pending_evaluation_iteration_resumes` fans a `resume=True` Celery +call out to every thin `evaluation_iteration_run` row still `PROCESSING`; the +Celery enqueue itself is the only external boundary — the DB is real. +""" + +from unittest.mock import patch + +from sqlmodel import Session + +from app.crud.evaluations.cron import dispatch_pending_evaluation_iteration_resumes +from app.crud.evaluations.iteration import ( + create_evaluation_iteration_run, + update_evaluation_iteration_run, +) +from app.models.evaluation_iteration import ( + EvaluationIterationRunUpdate, + EvaluationIterationStatusEnum, +) +from app.tests.utils.auth import TestAuthContext +from app.tests.utils.test_data import ( + create_test_config, + create_test_evaluation_dataset, +) + +_CALLBACK_URL = "https://example.com/callback" + + +def _make_iteration_run( + db: Session, + user_api_key: TestAuthContext, + experiment_name: str, + status: EvaluationIterationStatusEnum = EvaluationIterationStatusEnum.PROCESSING, +): + dataset = create_test_evaluation_dataset( + db=db, + organization_id=user_api_key.organization_id, + project_id=user_api_key.project_id, + ) + config = create_test_config( + db=db, project_id=user_api_key.project_id, use_kaapi_schema=True + ) + run = create_evaluation_iteration_run( + session=db, + dataset_id=dataset.id, + experiment_name=experiment_name, + config_id=config.id, + initial_config_version=1, + callback_url=_CALLBACK_URL, + organization_id=user_api_key.organization_id, + project_id=user_api_key.project_id, + ) + if status != EvaluationIterationStatusEnum.PROCESSING: + run = update_evaluation_iteration_run( + session=db, + iteration_run=run, + update=EvaluationIterationRunUpdate(status=status), + ) + return run + + +class TestDispatchPendingEvaluationIterationResumes: + def test_dispatches_exactly_one_resume_per_processing_row( + self, db: Session, user_api_key: TestAuthContext + ) -> None: + processing = _make_iteration_run(db, user_api_key, "cron-processing") + _make_iteration_run( + db, user_api_key, "cron-completed", EvaluationIterationStatusEnum.COMPLETED + ) + _make_iteration_run( + db, user_api_key, "cron-failed", EvaluationIterationStatusEnum.FAILED + ) + + with patch("app.celery.utils.start_evaluation_iteration_round") as mock_start: + summary = dispatch_pending_evaluation_iteration_resumes(session=db) + + mock_start.assert_called_once_with( + iteration_run_id=processing.id, + resume=True, + organization_id=user_api_key.organization_id, + project_id=user_api_key.project_id, + ) + assert summary["total"] == 1 + assert summary["resumes_dispatched"] == 1 + + def test_no_processing_rows_dispatches_nothing( + self, db: Session, user_api_key: TestAuthContext + ) -> None: + _make_iteration_run( + db, + user_api_key, + "cron-none-completed", + EvaluationIterationStatusEnum.COMPLETED, + ) + + with patch("app.celery.utils.start_evaluation_iteration_round") as mock_start: + summary = dispatch_pending_evaluation_iteration_resumes(session=db) + + mock_start.assert_not_called() + assert summary == {"total": 0, "resumes_dispatched": 0} diff --git a/backend/app/tests/crud/evaluations/test_iteration.py b/backend/app/tests/crud/evaluations/test_iteration.py new file mode 100644 index 000000000..61c03f802 --- /dev/null +++ b/backend/app/tests/crud/evaluations/test_iteration.py @@ -0,0 +1,156 @@ +"""CRUD tests for the thin `evaluation_iteration_run` tracking row. + +The round-by-round trajectory itself lives in the LangGraph checkpoint, not +here — see app/tests/services/evaluations/test_iteration_graph.py. +""" + +from sqlmodel import Session + +from app.crud.evaluations.iteration import ( + create_evaluation_iteration_run, + get_evaluation_iteration_run_by_id, + list_processing_evaluation_iteration_runs, + update_evaluation_iteration_run, +) +from app.models.evaluation_iteration import ( + EvaluationIterationRunUpdate, + EvaluationIterationStatusEnum, +) +from app.tests.utils.auth import TestAuthContext +from app.tests.utils.test_data import ( + create_test_config, + create_test_evaluation_dataset, +) + +_CALLBACK_URL = "https://example.com/callback" + + +def _make_dataset_and_config(db: Session, user_api_key: TestAuthContext): + dataset = create_test_evaluation_dataset( + db=db, + organization_id=user_api_key.organization_id, + project_id=user_api_key.project_id, + ) + config = create_test_config( + db=db, project_id=user_api_key.project_id, use_kaapi_schema=True + ) + return dataset, config + + +def _make_run(db: Session, user_api_key: TestAuthContext, experiment_name: str): + dataset, config = _make_dataset_and_config(db, user_api_key) + return create_evaluation_iteration_run( + session=db, + dataset_id=dataset.id, + experiment_name=experiment_name, + config_id=config.id, + initial_config_version=1, + callback_url=_CALLBACK_URL, + organization_id=user_api_key.organization_id, + project_id=user_api_key.project_id, + ) + + +class TestCreateEvaluationIterationRun: + def test_creates_row_with_processing_status( + self, db: Session, user_api_key: TestAuthContext + ) -> None: + run = _make_run(db, user_api_key, "crud-create") + + assert run.id is not None + assert run.status == EvaluationIterationStatusEnum.PROCESSING + assert run.stop_reason is None + assert run.error_message is None + + +class TestGetEvaluationIterationRunById: + def test_returns_none_when_missing( + self, db: Session, user_api_key: TestAuthContext + ) -> None: + found = get_evaluation_iteration_run_by_id( + session=db, + iteration_run_id=999_999, + organization_id=user_api_key.organization_id, + project_id=user_api_key.project_id, + ) + assert found is None + + def test_returns_none_for_a_different_project_scope( + self, + db: Session, + user_api_key: TestAuthContext, + superuser_api_key: TestAuthContext, + ) -> None: + run = _make_run(db, user_api_key, "crud-scope") + + found = get_evaluation_iteration_run_by_id( + session=db, + iteration_run_id=run.id, + organization_id=superuser_api_key.organization_id, + project_id=superuser_api_key.project_id, + ) + assert found is None + + def test_returns_row_for_matching_scope( + self, db: Session, user_api_key: TestAuthContext + ) -> None: + run = _make_run(db, user_api_key, "crud-match") + + found = get_evaluation_iteration_run_by_id( + session=db, + iteration_run_id=run.id, + organization_id=user_api_key.organization_id, + project_id=user_api_key.project_id, + ) + assert found is not None + assert found.id == run.id + + +class TestListProcessingEvaluationIterationRuns: + def test_only_processing_rows_are_returned( + self, db: Session, user_api_key: TestAuthContext + ) -> None: + processing = _make_run(db, user_api_key, "crud-list-processing") + completed = _make_run(db, user_api_key, "crud-list-completed") + failed = _make_run(db, user_api_key, "crud-list-failed") + update_evaluation_iteration_run( + session=db, + iteration_run=completed, + update=EvaluationIterationRunUpdate( + status=EvaluationIterationStatusEnum.COMPLETED + ), + ) + update_evaluation_iteration_run( + session=db, + iteration_run=failed, + update=EvaluationIterationRunUpdate( + status=EvaluationIterationStatusEnum.FAILED + ), + ) + + ids = {r.id for r in list_processing_evaluation_iteration_runs(session=db)} + + assert processing.id in ids + assert completed.id not in ids + assert failed.id not in ids + + +class TestUpdateEvaluationIterationRun: + def test_partial_update_only_touches_set_fields( + self, db: Session, user_api_key: TestAuthContext + ) -> None: + run = _make_run(db, user_api_key, "crud-update") + + updated = update_evaluation_iteration_run( + session=db, + iteration_run=run, + update=EvaluationIterationRunUpdate( + status=EvaluationIterationStatusEnum.FAILED, + error_message="boom", + ), + ) + + assert updated.status == EvaluationIterationStatusEnum.FAILED + assert updated.error_message == "boom" + # stop_reason was never set on the update payload, so it stays untouched. + assert updated.stop_reason is None diff --git a/backend/app/tests/services/evaluations/test_iteration.py b/backend/app/tests/services/evaluations/test_iteration.py new file mode 100644 index 000000000..8f283daf7 --- /dev/null +++ b/backend/app/tests/services/evaluations/test_iteration.py @@ -0,0 +1,253 @@ +"""Tests for the eval-iteration kickoff (`validate_and_start_evaluation_iteration`) +and the round-scoring helper (`compute_round_scores`). + +The LangGraph loop itself (`iteration_graph.py`) is covered separately in +`test_iteration_graph.py`. External boundary here is the Celery enqueue helper +(`start_evaluation_iteration_round`) — the DB is real. +""" + +from unittest.mock import patch +from uuid import uuid4 + +import pytest +from fastapi import HTTPException +from sqlmodel import Session, select + +from app.core.config import settings +from app.crud.evaluations.score import ( + GROUND_TRUTH_SCORE_NAME, + KNOWLEDGE_BASE_SCORE_NAME, + PROMPT_SCORE_NAME, +) +from app.models import Config, EvaluationDataset, EvaluationRun +from app.models.evaluation_iteration import ( + EvaluationIterationRun, + EvaluationIterationStatusEnum, +) +from app.models.llm.request import ConfigBlob, KaapiCompletionConfig +from app.services.evaluations.iteration import ( + compute_round_scores, + validate_and_start_evaluation_iteration, +) +from app.tests.utils.auth import TestAuthContext +from app.tests.utils.test_data import ( + create_test_config, + create_test_evaluation_dataset, +) + +_CALLBACK_URL = "https://example.com/callback" + + +def _summary_score(name: str, avg: float) -> dict: + return {"name": name, "avg": avg} + + +def _eval_run_with_scores(scores: list[dict]) -> EvaluationRun: + """A plain (non-persisted) EvaluationRun — compute_round_scores only reads .score/.id.""" + return EvaluationRun( + id=1, + run_name="scoring-test", + dataset_name="d", + dataset_id=1, + organization_id=1, + project_id=1, + score={"summary_scores": scores}, + ) + + +class TestComputeRoundScores: + def test_missing_ground_truth_metric_returns_none(self) -> None: + eval_run = _eval_run_with_scores([_summary_score(PROMPT_SCORE_NAME, 0.8)]) + assert compute_round_scores(eval_run) is None + + def test_missing_prompt_metric_returns_none(self) -> None: + eval_run = _eval_run_with_scores([_summary_score(GROUND_TRUTH_SCORE_NAME, 0.8)]) + assert compute_round_scores(eval_run) is None + + def test_both_present_computes_mean_stop_score_and_kb_score(self) -> None: + eval_run = _eval_run_with_scores( + [ + _summary_score(GROUND_TRUTH_SCORE_NAME, 0.8), + _summary_score(PROMPT_SCORE_NAME, 0.6), + _summary_score(KNOWLEDGE_BASE_SCORE_NAME, 0.4), + ] + ) + stop_score, kb_score = compute_round_scores(eval_run) + assert stop_score == pytest.approx(0.7) + assert kb_score == pytest.approx(0.4) + + def test_kb_score_is_none_when_metric_absent(self) -> None: + eval_run = _eval_run_with_scores( + [ + _summary_score(GROUND_TRUTH_SCORE_NAME, 1.0), + _summary_score(PROMPT_SCORE_NAME, 0.5), + ] + ) + stop_score, kb_score = compute_round_scores(eval_run) + assert stop_score == pytest.approx(0.75) + assert kb_score is None + + +def _make_dataset(*, db: Session, user_api_key: TestAuthContext) -> EvaluationDataset: + return create_test_evaluation_dataset( + db=db, + organization_id=user_api_key.organization_id, + project_id=user_api_key.project_id, + original_items_count=3, + duplication_factor=1, + ) + + +def _make_text_config(db: Session, project_id: int) -> Config: + blob = ConfigBlob( + completion=KaapiCompletionConfig( + provider="openai", + type="text", + params={"model": "gpt-4o-iteration-test", "temperature": 0.7}, + ) + ) + return create_test_config( + db=db, project_id=project_id, use_kaapi_schema=True, config_blob=blob + ) + + +class TestValidateAndStartEvaluationIteration: + def test_missing_dataset_raises_404( + self, db: Session, user_api_key: TestAuthContext + ) -> None: + config = _make_text_config(db, user_api_key.project_id) + + with pytest.raises(HTTPException) as exc: + validate_and_start_evaluation_iteration( + session=db, + dataset_id=999_999, + experiment_name="missing-dataset", + config_id=config.id, + config_version=1, + max_rounds=None, + callback_url=_CALLBACK_URL, + organization_id=user_api_key.organization_id, + project_id=user_api_key.project_id, + ) + assert exc.value.status_code == 404 + + def test_missing_config_raises_400( + self, db: Session, user_api_key: TestAuthContext + ) -> None: + dataset = _make_dataset(db=db, user_api_key=user_api_key) + + with pytest.raises(HTTPException) as exc: + validate_and_start_evaluation_iteration( + session=db, + dataset_id=dataset.id, + experiment_name="missing-config", + config_id=uuid4(), + config_version=1, + max_rounds=None, + callback_url=_CALLBACK_URL, + organization_id=user_api_key.organization_id, + project_id=user_api_key.project_id, + ) + assert exc.value.status_code == 400 + + def test_success_creates_thin_row_and_dispatches_round_1( + self, db: Session, user_api_key: TestAuthContext + ) -> None: + dataset = _make_dataset(db=db, user_api_key=user_api_key) + config = _make_text_config(db, user_api_key.project_id) + + with patch( + "app.services.evaluations.iteration.start_evaluation_iteration_round", + return_value="fake-task-id", + ) as mock_start: + iteration_run = validate_and_start_evaluation_iteration( + session=db, + dataset_id=dataset.id, + experiment_name="iter-exp", + config_id=config.id, + config_version=1, + max_rounds=None, + callback_url=_CALLBACK_URL, + organization_id=user_api_key.organization_id, + project_id=user_api_key.project_id, + ) + + assert iteration_run.id is not None + assert iteration_run.status == EvaluationIterationStatusEnum.PROCESSING + assert iteration_run.dataset_id == dataset.id + assert iteration_run.config_id == config.id + assert iteration_run.initial_config_version == 1 + assert iteration_run.callback_url == _CALLBACK_URL + + mock_start.assert_called_once_with( + iteration_run_id=iteration_run.id, + resume=False, + organization_id=user_api_key.organization_id, + project_id=user_api_key.project_id, + max_rounds=settings.EVAL_ITERATION_MAX_ROUNDS_DEFAULT, + config_version=1, + trace_id="N/A", + ) + + persisted = db.get(EvaluationIterationRun, iteration_run.id) + assert persisted is not None + assert persisted.status == EvaluationIterationStatusEnum.PROCESSING + + def test_max_rounds_above_hard_cap_is_clamped( + self, db: Session, user_api_key: TestAuthContext + ) -> None: + dataset = _make_dataset(db=db, user_api_key=user_api_key) + config = _make_text_config(db, user_api_key.project_id) + + with patch( + "app.services.evaluations.iteration.start_evaluation_iteration_round", + return_value="fake-task-id", + ) as mock_start: + validate_and_start_evaluation_iteration( + session=db, + dataset_id=dataset.id, + experiment_name="iter-clamp", + config_id=config.id, + config_version=1, + max_rounds=settings.EVAL_ITERATION_MAX_ROUNDS_HARD_CAP + 50, + callback_url=_CALLBACK_URL, + organization_id=user_api_key.organization_id, + project_id=user_api_key.project_id, + ) + + assert ( + mock_start.call_args.kwargs["max_rounds"] + == settings.EVAL_ITERATION_MAX_ROUNDS_HARD_CAP + ) + + def test_enqueue_failure_marks_row_failed_and_raises_500( + self, db: Session, user_api_key: TestAuthContext + ) -> None: + dataset = _make_dataset(db=db, user_api_key=user_api_key) + config = _make_text_config(db, user_api_key.project_id) + + with patch( + "app.services.evaluations.iteration.start_evaluation_iteration_round", + side_effect=RuntimeError("celery down"), + ): + with pytest.raises(HTTPException) as exc: + validate_and_start_evaluation_iteration( + session=db, + dataset_id=dataset.id, + experiment_name="iter-enqueue-fail", + config_id=config.id, + config_version=1, + max_rounds=None, + callback_url=_CALLBACK_URL, + organization_id=user_api_key.organization_id, + project_id=user_api_key.project_id, + ) + + assert exc.value.status_code == 500 + failed = db.exec( + select(EvaluationIterationRun).where( + EvaluationIterationRun.experiment_name == "iter-enqueue-fail" + ) + ).first() + assert failed is not None + assert failed.status == EvaluationIterationStatusEnum.FAILED diff --git a/backend/app/tests/services/evaluations/test_iteration_graph.py b/backend/app/tests/services/evaluations/test_iteration_graph.py new file mode 100644 index 000000000..ea5af8216 --- /dev/null +++ b/backend/app/tests/services/evaluations/test_iteration_graph.py @@ -0,0 +1,611 @@ +"""Tests for the eval-iterate-improve LangGraph loop (`iteration_graph.py`). + +Every node opens its own `Session(engine)` (see the module docstring on why), +so tests redirect that to the transactional `db` fixture the same way +`test_evaluation_fast.py` does for `execute_fast_evaluation_chunk`. The one +external HTTP boundary is `send_callback` (finalize_node's webhook delivery); +`get_webhook_secret` is a DB lookup and is left real per the implementer's +mocking guidance. + +Interrupt/resume is exercised through the *compiled* graph with an injected +`InMemorySaver` (per the implementer's guidance) rather than by calling +`wait_eval_node`/`wait_improve_node` directly — `interrupt()` requires a live +LangGraph runnable context (it reads a contextvar-backed config), so calling +it outside `graph.invoke(...)` raises a plain `RuntimeError`, not the +`GraphInterrupt` the real caller (the pregel executor) would swallow. The +terminal branches (failed/completed/ceiling/max_rounds) never call +`interrupt()`, so those are exercised as direct node calls. +""" + +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +import pytest +from langgraph.checkpoint.memory import InMemorySaver +from langgraph.types import Command +from sqlmodel import Session + +from app.core.config import settings +from app.crud.evaluations.iteration import create_evaluation_iteration_run +from app.crud.evaluations.score import ( + GROUND_TRUTH_SCORE_NAME, + KNOWLEDGE_BASE_SCORE_NAME, + PROMPT_SCORE_NAME, +) +from app.crud.jobs import JobCrud +from app.models import EvaluationDataset, EvaluationRun +from app.models.evaluation import RunModeEnum +from app.models.evaluation_iteration import ( + EvaluationIterationRun, + EvaluationIterationStatusEnum, +) +from app.models.job import JobStatus, JobType, JobUpdate +from app.services.evaluations.iteration import ( + STOP_REASON_CEILING_REACHED, + STOP_REASON_MAX_ROUNDS_REACHED, + STOP_REASON_ROUND_FAILED, +) +from app.services.evaluations.iteration_graph import ( + build_evaluation_iteration_graph, + execute_evaluation_iteration_graph_step, + finalize_node, + route_after_eval, + wait_eval_node, + wait_improve_node, +) +from app.tests.utils.auth import TestAuthContext +from app.tests.utils.test_data import ( + create_test_config, + create_test_evaluation_dataset, +) +from app.tests.utils.utils import random_lower_string + +_CALLBACK_URL = "https://example.com/callback" + + +class _FakeSessionCtx: + """Context manager returning the test session; `__exit__` never closes it. + + Mirrors the pattern in test_evaluation_fast.py — node code opens its own + `Session(engine)`, which must be redirected to the test's transactional + session or it would talk to a different, uncommitted connection. + """ + + def __init__(self, db: Session) -> None: + self._db = db + + def __enter__(self) -> Session: + return self._db + + def __exit__(self, *exc: object) -> bool: + return False + + +def _patch_session(db: Session): + return patch( + "app.services.evaluations.iteration_graph.Session", + lambda *a, **k: _FakeSessionCtx(db), + ) + + +def _make_dataset(db: Session, user_api_key: TestAuthContext) -> EvaluationDataset: + return create_test_evaluation_dataset( + db=db, + organization_id=user_api_key.organization_id, + project_id=user_api_key.project_id, + ) + + +def _make_eval_run( + db: Session, + user_api_key: TestAuthContext, + *, + status: str = "processing", + score: dict | None = None, + error_message: str | None = None, +) -> EvaluationRun: + dataset = _make_dataset(db, user_api_key) + config = create_test_config( + db=db, project_id=user_api_key.project_id, use_kaapi_schema=True + ) + run = EvaluationRun( + run_name=f"iter-round-{random_lower_string()}", + dataset_name=dataset.name, + dataset_id=dataset.id, + config_id=config.id, + config_version=1, + status=status, + run_mode=RunModeEnum.FAST, + is_judge_run=True, + score=score, + error_message=error_message, + organization_id=user_api_key.organization_id, + project_id=user_api_key.project_id, + ) + db.add(run) + db.commit() + db.refresh(run) + return run + + +def _make_iteration_run( + db: Session, user_api_key: TestAuthContext +) -> EvaluationIterationRun: + dataset = _make_dataset(db, user_api_key) + config = create_test_config( + db=db, project_id=user_api_key.project_id, use_kaapi_schema=True + ) + return create_evaluation_iteration_run( + session=db, + dataset_id=dataset.id, + experiment_name=f"iter-{random_lower_string()}", + config_id=config.id, + initial_config_version=1, + callback_url=_CALLBACK_URL, + organization_id=user_api_key.organization_id, + project_id=user_api_key.project_id, + ) + + +def _good_score(ground_truth: float, prompt: float) -> dict: + return { + "summary_scores": [ + {"name": GROUND_TRUTH_SCORE_NAME, "avg": ground_truth}, + {"name": PROMPT_SCORE_NAME, "avg": prompt}, + ] + } + + +def _base_state(**overrides) -> dict: + state = { + "iteration_run_id": 1, + "dataset_id": 1, + "experiment_name": "exp", + "config_id": str(uuid4()), + "config_version": 1, + "round_number": 1, + "max_rounds": 10, + "current_eval_run_id": None, + "current_improvement_job_id": None, + "history": [], + "best_round_number": None, + "best_config_version": None, + "best_stop_score": None, + "consecutive_low_delta_rounds": 0, + "stop_reason": None, + "error_message": None, + "organization_id": 1, + "project_id": 1, + "callback_url": _CALLBACK_URL, + } + state.update(overrides) + return state + + +class TestGraphInterruptAndResume: + """One full round-trip through the compiled graph: both wait nodes pause + on a non-terminal sub-run and resume correctly once it completes.""" + + def test_eval_wait_then_improve_wait_interrupt_and_resume( + self, db: Session, user_api_key: TestAuthContext + ) -> None: + round1_eval_run = _make_eval_run(db, user_api_key, status="processing") + round2_eval_run = _make_eval_run(db, user_api_key, status="processing") + eval_runs = iter([round1_eval_run, round2_eval_run]) + + checkpointer = InMemorySaver() + graph = build_evaluation_iteration_graph(checkpointer) + thread_config = { + "configurable": {"thread_id": f"test-thread-{round1_eval_run.id}"} + } + state = _base_state( + organization_id=user_api_key.organization_id, + project_id=user_api_key.project_id, + ) + + with ( + _patch_session(db), + patch( + "app.services.evaluations.iteration_graph.validate_and_start_fast_evaluation", + side_effect=lambda **_: next(eval_runs), + ), + ): + result = graph.invoke(state, config=thread_config) + + assert "__interrupt__" in result + assert result["__interrupt__"][0].value == { + "waiting_on": "eval", + "eval_run_id": round1_eval_run.id, + } + assert graph.get_state(thread_config).next == ("wait_eval_node",) + + round1_eval_run.status = "completed" + round1_eval_run.score = _good_score(0.9, 0.8) + db.add(round1_eval_run) + db.commit() + + improve_job_holder: dict = {} + + def _fake_start_improve(**kwargs): + job = JobCrud(session=kwargs["session"]).create( + job_type=JobType.PROMPT_IMPROVEMENT, + project_id=user_api_key.project_id, + ) + improve_job_holder["job"] = job + return job + + with ( + _patch_session(db), + patch( + "app.services.evaluations.iteration_graph.start_prompt_improvement_job", + side_effect=_fake_start_improve, + ), + ): + result = graph.invoke(Command(resume=True), config=thread_config) + + assert "__interrupt__" in result + assert result["__interrupt__"][0].value == { + "waiting_on": "improve", + "job_id": str(improve_job_holder["job"].id), + } + assert graph.get_state(thread_config).next == ("wait_improve_node",) + + JobCrud(session=db).update( + improve_job_holder["job"].id, + JobUpdate(status=JobStatus.SUCCESS, meta={"version": 2}), + ) + + with ( + _patch_session(db), + patch( + "app.services.evaluations.iteration_graph.validate_and_start_fast_evaluation", + side_effect=lambda **_: next(eval_runs), + ), + ): + result = graph.invoke(Command(resume=True), config=thread_config) + + # Looped back to start_eval_node for round 2, using the improved + # config_version, and is now waiting on the second eval run. + assert "__interrupt__" in result + assert result["__interrupt__"][0].value == { + "waiting_on": "eval", + "eval_run_id": round2_eval_run.id, + } + snapshot = graph.get_state(thread_config).values + assert snapshot["round_number"] == 2 + assert snapshot["config_version"] == 2 + assert len(snapshot["history"]) == 1 + + +class TestWaitEvalNodeBranches: + def test_failed_eval_run_sets_round_failed( + self, db: Session, user_api_key: TestAuthContext + ) -> None: + eval_run = _make_eval_run( + db, user_api_key, status="failed", error_message="upstream boom" + ) + state = _base_state( + current_eval_run_id=eval_run.id, + organization_id=user_api_key.organization_id, + project_id=user_api_key.project_id, + ) + + with _patch_session(db): + result = wait_eval_node(state) + + assert result["stop_reason"] == STOP_REASON_ROUND_FAILED + assert "upstream boom" in result["error_message"] + + def test_completed_good_scores_continues_without_stop_reason( + self, db: Session, user_api_key: TestAuthContext + ) -> None: + eval_run = _make_eval_run( + db, user_api_key, status="completed", score=_good_score(0.9, 0.7) + ) + state = _base_state( + current_eval_run_id=eval_run.id, + round_number=1, + max_rounds=10, + organization_id=user_api_key.organization_id, + project_id=user_api_key.project_id, + ) + + with _patch_session(db): + result = wait_eval_node(state) + + assert "stop_reason" not in result + assert len(result["history"]) == 1 + assert result["history"][0]["stop_score"] == pytest.approx(0.8) + assert result["best_round_number"] == 1 + + def test_third_consecutive_low_delta_round_triggers_ceiling_reached( + self, db: Session, user_api_key: TestAuthContext + ) -> None: + # Round 4's stop_score (0.72) sits within EVAL_ITERATION_CEILING_DELTA_THRESHOLD + # of round 3's (0.715) — the third consecutive low-delta round. + eval_run = _make_eval_run( + db, user_api_key, status="completed", score=_good_score(0.72, 0.72) + ) + history = [ + { + "round_number": 1, + "eval_run_id": 101, + "config_version": 1, + "stop_score": 0.60, + "kb_score": None, + }, + { + "round_number": 2, + "eval_run_id": 102, + "config_version": 2, + "stop_score": 0.70, + "kb_score": None, + }, + { + "round_number": 3, + "eval_run_id": 103, + "config_version": 3, + "stop_score": 0.715, + "kb_score": None, + }, + ] + state = _base_state( + current_eval_run_id=eval_run.id, + round_number=4, + max_rounds=10, + history=history, + consecutive_low_delta_rounds=2, + best_stop_score=0.715, + best_round_number=3, + best_config_version=3, + organization_id=user_api_key.organization_id, + project_id=user_api_key.project_id, + ) + + with _patch_session(db): + result = wait_eval_node(state) + + assert result["consecutive_low_delta_rounds"] == 3 + assert result["stop_reason"] == STOP_REASON_CEILING_REACHED + + def test_reaching_max_rounds_sets_max_rounds_reached( + self, db: Session, user_api_key: TestAuthContext + ) -> None: + # A big score jump resets consecutive_low_delta_rounds to 0 — only the + # round-cap should trigger the stop here, not the ceiling. + eval_run = _make_eval_run( + db, user_api_key, status="completed", score=_good_score(0.95, 0.95) + ) + history = [ + { + "round_number": 1, + "eval_run_id": 101, + "config_version": 1, + "stop_score": 0.40, + "kb_score": None, + }, + { + "round_number": 2, + "eval_run_id": 102, + "config_version": 2, + "stop_score": 0.45, + "kb_score": None, + }, + ] + state = _base_state( + current_eval_run_id=eval_run.id, + round_number=3, + max_rounds=3, + history=history, + consecutive_low_delta_rounds=1, + best_stop_score=0.45, + best_round_number=2, + best_config_version=2, + organization_id=user_api_key.organization_id, + project_id=user_api_key.project_id, + ) + + with _patch_session(db): + result = wait_eval_node(state) + + assert result["consecutive_low_delta_rounds"] == 0 + assert result["stop_reason"] == STOP_REASON_MAX_ROUNDS_REACHED + + +class TestRouteAfterEval: + def test_routes_to_finalize_node_when_stop_reason_is_set(self) -> None: + assert ( + route_after_eval({"stop_reason": STOP_REASON_CEILING_REACHED}) + == "finalize_node" + ) + + def test_routes_to_start_improve_node_when_no_stop_reason(self) -> None: + assert route_after_eval({"stop_reason": None}) == "start_improve_node" + + +class TestWaitImproveNodeBranches: + def test_failed_job_sets_round_failed( + self, db: Session, user_api_key: TestAuthContext + ) -> None: + job = JobCrud(session=db).create( + job_type=JobType.PROMPT_IMPROVEMENT, project_id=user_api_key.project_id + ) + JobCrud(session=db).update( + job.id, JobUpdate(status=JobStatus.FAILED, error_message="llm down") + ) + state = _base_state( + current_improvement_job_id=str(job.id), + organization_id=user_api_key.organization_id, + project_id=user_api_key.project_id, + ) + + with _patch_session(db): + result = wait_improve_node(state) + + assert result["stop_reason"] == STOP_REASON_ROUND_FAILED + assert "llm down" in result["error_message"] + + def test_success_job_advances_round_and_config_version( + self, db: Session, user_api_key: TestAuthContext + ) -> None: + job = JobCrud(session=db).create( + job_type=JobType.PROMPT_IMPROVEMENT, project_id=user_api_key.project_id + ) + JobCrud(session=db).update( + job.id, JobUpdate(status=JobStatus.SUCCESS, meta={"version": 7}) + ) + state = _base_state( + current_improvement_job_id=str(job.id), + round_number=2, + organization_id=user_api_key.organization_id, + project_id=user_api_key.project_id, + ) + + with _patch_session(db): + result = wait_improve_node(state) + + assert result["round_number"] == 3 + assert result["config_version"] == 7 + assert result["current_improvement_job_id"] is None + + +class TestFinalizeNode: + def test_ceiling_reached_persists_completed_status( + self, db: Session, user_api_key: TestAuthContext + ) -> None: + iteration_run = _make_iteration_run(db, user_api_key) + state = _base_state( + iteration_run_id=iteration_run.id, + organization_id=user_api_key.organization_id, + project_id=user_api_key.project_id, + stop_reason=STOP_REASON_CEILING_REACHED, + history=[ + { + "round_number": 1, + "eval_run_id": 11, + "config_version": 1, + "stop_score": 0.7, + "kb_score": None, + }, + ], + best_round_number=1, + ) + + with _patch_session(db), patch( + "app.services.evaluations.iteration_graph.send_callback" + ) as mock_send: + finalize_node(state) + + db.expire_all() + persisted = db.get(EvaluationIterationRun, iteration_run.id) + assert persisted.status == EvaluationIterationStatusEnum.COMPLETED + assert persisted.stop_reason == STOP_REASON_CEILING_REACHED + mock_send.assert_called_once() + + def test_round_failed_persists_failed_status_with_error_message( + self, db: Session, user_api_key: TestAuthContext + ) -> None: + iteration_run = _make_iteration_run(db, user_api_key) + state = _base_state( + iteration_run_id=iteration_run.id, + organization_id=user_api_key.organization_id, + project_id=user_api_key.project_id, + stop_reason=STOP_REASON_ROUND_FAILED, + error_message="round blew up", + history=[], + ) + + with _patch_session(db), patch( + "app.services.evaluations.iteration_graph.send_callback" + ): + finalize_node(state) + + db.expire_all() + persisted = db.get(EvaluationIterationRun, iteration_run.id) + assert persisted.status == EvaluationIterationStatusEnum.FAILED + assert persisted.error_message == "round blew up" + + def test_callback_payload_shape_and_best_round_is_highest_score_not_last( + self, db: Session, user_api_key: TestAuthContext + ) -> None: + iteration_run = _make_iteration_run(db, user_api_key) + history = [ + { + "round_number": 1, + "eval_run_id": 11, + "config_version": 1, + "stop_score": 0.60, + "kb_score": None, + }, + { + "round_number": 2, + "eval_run_id": 12, + "config_version": 2, + "stop_score": 0.92, + "kb_score": 0.5, + }, + { + "round_number": 3, + "eval_run_id": 13, + "config_version": 3, + "stop_score": 0.55, + "kb_score": None, + }, + ] + state = _base_state( + iteration_run_id=iteration_run.id, + organization_id=user_api_key.organization_id, + project_id=user_api_key.project_id, + stop_reason=STOP_REASON_MAX_ROUNDS_REACHED, + history=history, + best_round_number=2, + callback_url=_CALLBACK_URL, + ) + + with _patch_session(db), patch( + "app.services.evaluations.iteration_graph.send_callback" + ) as mock_send: + finalize_node(state) + + mock_send.assert_called_once() + args, kwargs = mock_send.call_args + assert args[0] == _CALLBACK_URL + envelope = args[1] + report = envelope["data"] + + assert report["iteration_run_id"] == iteration_run.id + assert report["stop_reason"] == STOP_REASON_MAX_ROUNDS_REACHED + assert len(report["history"]) == 3 + # The best round is round 2 (highest stop_score), not round 3 (the last). + assert report["best_round"]["round_number"] == 2 + assert report["best_round"]["round_number"] != history[-1]["round_number"] + assert report["best_round"]["stop_score"] == pytest.approx(0.92) + + +class TestExecuteEvaluationIterationGraphStep: + def test_uncaught_exception_marks_thin_row_failed_and_reraises( + self, db: Session, user_api_key: TestAuthContext + ) -> None: + iteration_run = _make_iteration_run(db, user_api_key) + + with ( + _patch_session(db), + patch( + "app.services.evaluations.iteration_graph._run_graph_step", + side_effect=RuntimeError("graph blew up"), + ), + ): + with pytest.raises(RuntimeError, match="graph blew up"): + execute_evaluation_iteration_graph_step( + iteration_run_id=iteration_run.id, + resume=False, + organization_id=user_api_key.organization_id, + project_id=user_api_key.project_id, + ) + + db.expire_all() + persisted = db.get(EvaluationIterationRun, iteration_run.id) + assert persisted.status == EvaluationIterationStatusEnum.FAILED + assert ( + persisted.error_message == "Evaluation iteration loop failed unexpectedly." + ) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 707add32e..2d8b42872 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -57,6 +57,8 @@ dependencies = [ "anthropic>=0.104.1", "google-cloud-storage>=3.10.1", "filetype>=1.2.0", + "langgraph>=1.2.10", + "langgraph-checkpoint-postgres>=3.1.1", ] [tool.uv] diff --git a/backend/uv.lock b/backend/uv.lock index 2ab854e81..cd9c09c86 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -272,6 +272,8 @@ dependencies = [ { name = "jinja2" }, { name = "jiwer" }, { name = "langfuse" }, + { name = "langgraph" }, + { name = "langgraph-checkpoint-postgres" }, { name = "litellm" }, { name = "moto", extra = ["s3"] }, { name = "numpy" }, @@ -339,6 +341,8 @@ requires-dist = [ { name = "jinja2", specifier = ">=3.1.4,<4.0.0" }, { name = "jiwer", specifier = ">=3.1.0" }, { name = "langfuse", specifier = "==4.7.1" }, + { name = "langgraph", specifier = ">=1.2.10" }, + { name = "langgraph-checkpoint-postgres", specifier = ">=3.1.1" }, { name = "litellm", specifier = ">=1.83.10" }, { name = "moto", extras = ["s3"], specifier = ">=5.1.1" }, { name = "numpy", specifier = ">=1.24.0" }, @@ -1822,6 +1826,27 @@ 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 = "jsonpointer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, +] + [[package]] name = "jsonschema" version = "4.23.0" @@ -1864,6 +1889,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/0f/834427d8c03ff1d7e867d3db3d176470c64871753252b21b4f4897d1fa45/kombu-5.6.2-py3-none-any.whl", hash = "sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93", size = 214219, upload-time = "2025-12-29T20:30:05.74Z" }, ] +[[package]] +name = "langchain-core" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpatch" }, + { name = "langchain-protocol" }, + { name = "langsmith" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/3e/63af6b9d76d9be907c7c524d6ec18a2efed7e0e2d123fea0230d78dbd73f/langchain_core-1.5.3.tar.gz", hash = "sha256:a56457ac444fef41e9404443c187f0ecea708d36e816ea4ba9573c027f7d1a2d", size = 972461, upload-time = "2026-07-30T14:55:55.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/e6/c7c39efe0bc7e1b7c3d8f54f85846e04c901913c3d3e99068b218558c6f1/langchain_core-1.5.3-py3-none-any.whl", hash = "sha256:48b56fa580277209594dd7baf837f5b9a2a3651613f34ff9fb1728b429df015f", size = 561687, upload-time = "2026-07-30T14:55:54.419Z" }, +] + +[[package]] +name = "langchain-protocol" +version = "0.0.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221, upload-time = "2026-06-18T17:08:25.996Z" }, +] + [[package]] name = "langfuse" version = "4.7.1" @@ -1883,6 +1940,105 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/9a/bd3368f46b6c72ee2068b80536826b02ae86df53eff1c79941344503098f/langfuse-4.7.1-py3-none-any.whl", hash = "sha256:a4e59c81ad5e5b16a65d3849f4923ebc3ad6e67ec803ada83d50c0cb66149490", size = 562571, upload-time = "2026-05-29T18:06:20.517Z" }, ] +[[package]] +name = "langgraph" +version = "1.2.10" +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/70/1d/a32f3caf4b3d60651656c0d64976b48d168653e81c71bb7512e9a31541aa/langgraph-1.2.10.tar.gz", hash = "sha256:05a183a746ed570a06c7c1b879920163509a75df9e44e92dd2238218d677fd37", size = 723404, upload-time = "2026-07-28T18:33:51.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/4d/3fc3e2535ee2c731130d71371848ebc6d4a9d2e8ae6060b11987ba134951/langgraph-1.2.10-py3-none-any.whl", hash = "sha256:52c48bd42fa31a1de0e1c0f0ebfe342e11ca2957b8b3563f83dbd60d8e30f921", size = 247753, upload-time = "2026-07-28T18:33:50.028Z" }, +] + +[[package]] +name = "langgraph-checkpoint" +version = "4.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "ormsgpack" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/47/886af6f886f0bff2273164a45f008694e48a96ff3cd25ff0228f2aa9480e/langgraph_checkpoint-4.1.1.tar.gz", hash = "sha256:6c2bdb530c91f91d7d9c1bd100925d0fc4f498d418c17f3587d1526279482a25", size = 184020, upload-time = "2026-05-22T16:57:38.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/b4/71425e3e38be92611300b9cc5e46a5bf98ab23f5ea8a75b73d02a2f1413c/langgraph_checkpoint-4.1.1-py3-none-any.whl", hash = "sha256:25d29144b082827218e7bc3f1e9b0566a4bb007895cd6cc26f66a8428739f56e", size = 56212, upload-time = "2026-05-22T16:57:37.203Z" }, +] + +[[package]] +name = "langgraph-checkpoint-postgres" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langgraph-checkpoint" }, + { name = "orjson" }, + { name = "psycopg" }, + { name = "psycopg-pool" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/92/1e8959f8cd1b56e672fde3227f6fd642be85af6c5fd662d73921074aa39d/langgraph_checkpoint_postgres-3.1.1.tar.gz", hash = "sha256:d320e147ddad8c374cd546df0b52b532dd54d0541dd9fd23fc738cbd5de76f41", size = 150413, upload-time = "2026-07-30T19:15:39.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/32/ba457698a48a0e18d786caa770033067049fbe36d6846f8e50f13b594b51/langgraph_checkpoint_postgres-3.1.1-py3-none-any.whl", hash = "sha256:6e353aecd8150de144fef8e51a49076f58b7d6830d4cf51392b7ad4d79832ba7", size = 50778, upload-time = "2026-07-30T19:15:37.405Z" }, +] + +[[package]] +name = "langgraph-prebuilt" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/66/ed9b93f56bc17ef22d551892f0ac2b225a97fe0fcf23a511b857f70d590b/langgraph_prebuilt-1.1.0.tar.gz", hash = "sha256:3c579cf6eed2d17f9c157c2d0fcaddcd8688524e7022d3b22b37a3bf4589d528", size = 178833, upload-time = "2026-05-12T03:37:49.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/43/3fe1a700b8490ed02679cdbbc8c915eb23a092faf496c9c1118abcd10be3/langgraph_prebuilt-1.1.0-py3-none-any.whl", hash = "sha256:51e311747d755b751d5c6b39b0c1446124d3a7643d2515017e6714b323508fc9", size = 41043, upload-time = "2026-05-12T03:37:48.007Z" }, +] + +[[package]] +name = "langgraph-sdk" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "langchain-core" }, + { name = "langchain-protocol" }, + { name = "orjson" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/2b/bd8ac26d4e97f6df88ef05ce5b6a38945a3903e1025d926f4752aa88aa97/langgraph_sdk-0.4.2.tar.gz", hash = "sha256:b88f0f5f6328ac0680d6790614a905b2bcfa257f2276dba4e38f0e86db0aa738", size = 348327, upload-time = "2026-06-01T17:51:19.856Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/05/aac507337cceae773c2cc9ab91eb6301963af7aeeb55b4217a00e15aff17/langgraph_sdk-0.4.2-py3-none-any.whl", hash = "sha256:75fa5096c1177ce39c847096a8fe3745ffd480ddb412995f836e9f5f884c43dd", size = 160521, upload-time = "2026-06-01T17:51:18.849Z" }, +] + +[[package]] +name = "langsmith" +version = "0.10.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "sniffio" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, + { name = "websockets" }, + { name = "xxhash" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/bb/bce9faa416dfd28e1cf60bf6299e9569f9e8483b0ed22eed1d6aefc9e81c/langsmith-0.10.15.tar.gz", hash = "sha256:eefc562b29eb642a635b459e5bb44ca574380d7f32fe840acf28cd603c168647", size = 4790873, upload-time = "2026-07-31T18:15:18.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/7a/58602b770741bc84b0b35b580914f335f6663f4ea699b95eb12b074e70b8/langsmith-0.10.15-py3-none-any.whl", hash = "sha256:7afd7979a9cdf846a88c980e0a31ed518c33631d29e672adbfbb33446f3817cf", size = 731606, upload-time = "2026-07-31T18:15:16.471Z" }, +] + [[package]] name = "librt" version = "0.8.1" @@ -2638,6 +2794,98 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3d/7f/5c1b7d4385852b9e5eacd4e7f9d8b565d3d351d17463b24916ad098adf1a/opentelemetry_util_http-0.62b0-py3-none-any.whl", hash = "sha256:c20462808d8cc95b69b0dc4a3e02a9d36beb663347e96c931f51ffd78bd318ad", size = 9294, upload-time = "2026-04-09T14:40:19.014Z" }, ] +[[package]] +name = "orjson" +version = "3.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, + { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, + { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, + { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, + { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, + { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, + { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, + { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, +] + +[[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 = "24.2" @@ -3043,6 +3291,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" }, ] +[[package]] +name = "psycopg-pool" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/82/7a23d26039827ecd4ebe93905651029ddd307c5182ad59296dfb6f67b528/psycopg_pool-3.3.1.tar.gz", hash = "sha256:b10b10b7a175d5cc1592147dc5b7eec8a9e0834eb3ed2c4a92c858e2f51eb63c", size = 31661, upload-time = "2026-05-01T23:31:59.809Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/ed/89c2c620af0e1660354cd8aabf9f5b21f911597ce22acb37c805d6c86bc8/psycopg_pool-3.3.1-py3-none-any.whl", hash = "sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5", size = 40023, upload-time = "2026-05-01T23:31:53.136Z" }, +] + [[package]] name = "py-partiql-parser" version = "0.6.3" @@ -4395,6 +4655,72 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] +[[package]] +name = "uuid-utils" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/91/63938e0e7e7876658e5e40178e7c0735b53527886fe11797a11699c55edd/uuid_utils-0.17.0.tar.gz", hash = "sha256:abb5667a36119019b3fa320c4d10c21ebccfcc87c8a739e6a0056cee7f48dde2", size = 43220, upload-time = "2026-07-09T13:49:58.433Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/80/a7e685968e3cec99d6fe2fb25d0f5726310e1bba356da68c13dfd8b7d140/uuid_utils-0.17.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:9205068badf453d2f0821fd5d340389b4679992d7ff79d4f3e5608996dd1b287", size = 556403, upload-time = "2026-07-09T13:48:27.022Z" }, + { url = "https://files.pythonhosted.org/packages/56/47/3102d93bcb7b0bfe6bede63ff8f221a7f91348e10a37f682773be27c56d9/uuid_utils-0.17.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0fcca4e838af9ac9243b3358d7c14afa4dca286a87781124c272d6c4cad9c968", size = 285608, upload-time = "2026-07-09T13:48:28.769Z" }, + { url = "https://files.pythonhosted.org/packages/55/fb/d59695f0f8db065b93c63316eaafa05a22d75a0486978a33736c52c646d5/uuid_utils-0.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f3729e839209f3457d0d8b6a35a376fdf65577a5aecaf4cc3587d3305759ba6", size = 319926, upload-time = "2026-07-09T13:48:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/5a/03/62fabcd1e990e07a0e220e8d552af45bc16f107fa8e55c2014a706bb1a1e/uuid_utils-0.17.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3dac0ad0cd9a2818d1775215365a4e8c2f8ada215529dd26f3f8cceeb67a6988", size = 327172, upload-time = "2026-07-09T13:48:31.187Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/a5081391338b459e2f8d8b12581f00f8caa6317fab510e0e85c18c59e938/uuid_utils-0.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e671b2322ef09106ecb1ca0f4c398b134d5e2c1f80d7a4f3336847a3072c0e94", size = 439075, upload-time = "2026-07-09T13:48:32.295Z" }, + { url = "https://files.pythonhosted.org/packages/59/30/91795bd01e17a13661280d4899fbf38fb05e3f38e873f9aaec106ec30aa0/uuid_utils-0.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8eb3e5caca8d3a6f72ea4cce024583f989f6f2e9186f98800213fff0176e8bcc", size = 320247, upload-time = "2026-07-09T13:48:33.64Z" }, + { url = "https://files.pythonhosted.org/packages/e5/11/09102b78303e4eb62069d6d88ef9fd661dc523e8f429e1fd67eaa78a6f44/uuid_utils-0.17.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8b72c2002202038666bf647f9a790906214c7c11cd0d6efef77b7d07bef3034a", size = 344738, upload-time = "2026-07-09T13:48:34.786Z" }, + { url = "https://files.pythonhosted.org/packages/74/f9/be95bad6954b60328878c3800258f01a6accd24fd75112d13f023462d53f/uuid_utils-0.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4e2ac1c0b56f2c91b6f158e29ed96b1503223fe8aa6e79b1be1dc55bd8a5131c", size = 496845, upload-time = "2026-07-09T13:48:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/02/8a19a34e0530d987488a068a71576a236f5c8c746630b870b57f71eb24ef/uuid_utils-0.17.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6c142bd0cb4dba31c10babe00d59f7ef6460f0ef55eaa9c1a9da270684af996a", size = 603233, upload-time = "2026-07-09T13:48:37.512Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a8/b1abab36ff73b0248d82179816467f6d39a2e80fd64329a895ca94f3508e/uuid_utils-0.17.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e252db239eb41c32248e096e0d170bce5896a4fd3405556362bc3dd83d912206", size = 561401, upload-time = "2026-07-09T13:48:38.977Z" }, + { url = "https://files.pythonhosted.org/packages/61/91/70e7b528b351cc03a9ca43e6116371cdde31bb12bcead7ca2ca1367366cc/uuid_utils-0.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:237722b6581bb5b4eb4cefbcbe5c6e2980a440aabe781fbe50ebf1cb71eee4cc", size = 525314, upload-time = "2026-07-09T13:48:40.599Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f6/9167e90cf9937d6558f92d022ff3024a69d938a514d9c8faa4080f73b001/uuid_utils-0.17.0-cp312-cp312-win32.whl", hash = "sha256:46a73cacdf512f473a81f65dbf84186e08cfe6e9118fa582b6c6b33a8288a30d", size = 166831, upload-time = "2026-07-09T13:48:41.862Z" }, + { url = "https://files.pythonhosted.org/packages/5c/7d/0b889654d9ee3413f810cf4685e241285f650d98a4103ac9f3c6bcc95f29/uuid_utils-0.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:e59b60a0a4cb7541480e02090d37dc2df3b72df4c2e776fff64ce3a4e3dd4637", size = 172944, upload-time = "2026-07-09T13:48:42.992Z" }, + { url = "https://files.pythonhosted.org/packages/be/35/8c6e1bf65e4d400352885dadc656ad6d0af96e89231e3f04686bc2197128/uuid_utils-0.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:d561a4c5747a1e6c7fa7c49a0292e78b4e8c456332caa084fc7abad8de828652", size = 172459, upload-time = "2026-07-09T13:48:44.271Z" }, + { url = "https://files.pythonhosted.org/packages/d2/dd/614fb9912157ac0128e6050859ccf06d9f13df9a944a803e8f80f6157e38/uuid_utils-0.17.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d11a7bc1e02da8984d32e6de9e0826c6edac00eac17de270f372bf32f9a0af63", size = 557259, upload-time = "2026-07-09T13:48:45.664Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/d072711704de3d21bec08b6c2f36a215200ca1d5e01a390ea1ac434080a0/uuid_utils-0.17.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7a49f47ac26df3e431c56b825c1bae8e6d3d591fdbb7438c227cc9845a7e3d73", size = 286271, upload-time = "2026-07-09T13:48:47.018Z" }, + { url = "https://files.pythonhosted.org/packages/18/6d/8a63e5eb2d5a6ba69a6c2036e305075bd6f5a022e7ea25fc6ce0eb7c51d2/uuid_utils-0.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32df1944808877702ceea398c103881c09a679bb672a215e01c2a84231266bf9", size = 320025, upload-time = "2026-07-09T13:48:48.208Z" }, + { url = "https://files.pythonhosted.org/packages/f7/2d/bdc2caf9719d9090d7c46043242ae6136cba4f7a7ee384992ab905ad9aa1/uuid_utils-0.17.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:98c88d3edd08e7245562e9815996dbc6f0bd4745e1c76462f24af5ae4e187dd1", size = 327931, upload-time = "2026-07-09T13:48:49.673Z" }, + { url = "https://files.pythonhosted.org/packages/b6/33/9219d09d51ead282b578b2a4e0a515c2cce3ec52076cada8bfb7e35727d5/uuid_utils-0.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a4370089c8b2e42f1db51d76408c7fa8eaa2934bf854d17983d16179c07c098", size = 438537, upload-time = "2026-07-09T13:48:50.842Z" }, + { url = "https://files.pythonhosted.org/packages/d8/79/e8e0f8b3955f2081c116157119d87659937893242eb834aa170da04d660b/uuid_utils-0.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:09a55b7a5ae764985cb46467496a1787678d0a1400356157a080ad95b1a36869", size = 320656, upload-time = "2026-07-09T13:48:52.164Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5e/d1ceddc430ff04b6e21704b2030d4438074a2f478b265dab43da957791c1/uuid_utils-0.17.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:56aa6488b931246fae11924e4bd0e2b32677e63945eecb71c29e3c2ca0dc3131", size = 345310, upload-time = "2026-07-09T13:48:54.076Z" }, + { url = "https://files.pythonhosted.org/packages/d5/62/89438e12f389a843e626b7e37691319a057b3d6b80914609106891faadda/uuid_utils-0.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:309a35f12d99dde19032bc2259cda6431c85eeac0879134dc777cc3087d7e1cb", size = 496771, upload-time = "2026-07-09T13:48:55.365Z" }, + { url = "https://files.pythonhosted.org/packages/87/d2/eedcd99f522d60e238ead03844f0d51743ba84d33044959e230b756bf212/uuid_utils-0.17.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:21c79b61ff750abcf057163dd764ccb6196cde7a26cda1b31b45cd97769e03b3", size = 603631, upload-time = "2026-07-09T13:48:56.746Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a8/bb1b38aaddd7243b6e562c6694f499bf094800918316192fd8cb2cdc2620/uuid_utils-0.17.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4134353bfe3026ddab8e886002dc52bc5a0ab04611aabb0eaae23c32e6e57f64", size = 562008, upload-time = "2026-07-09T13:48:58.241Z" }, + { url = "https://files.pythonhosted.org/packages/b4/77/5f7ed930dc105e293845c09e4d5bd84076318a12f45a46783e1af64906d7/uuid_utils-0.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7c89359affecebe2e39e6a116d069b363c936511a9572b308402489a26957d89", size = 525527, upload-time = "2026-07-09T13:48:59.784Z" }, + { url = "https://files.pythonhosted.org/packages/fd/25/1b55697adf6811a6f92cff6340e6b03e31fd6bc51066a5c10698c29b3679/uuid_utils-0.17.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:6a019a31bc4db89a0903a3e4f6b218571f3a6ff0ad4b3d3fe1c8f91a05ff6e3e", size = 97965, upload-time = "2026-07-09T13:49:01.217Z" }, + { url = "https://files.pythonhosted.org/packages/26/bf/cd729343de4684230be8a966bad7bfc2cf10ce3e643b1189a8b5370dbe35/uuid_utils-0.17.0-cp313-cp313-win32.whl", hash = "sha256:b3131a82d0c7611f0aa480a6d36929e001a3f54ba0fc029a8118a5863cce513c", size = 167316, upload-time = "2026-07-09T13:49:02.354Z" }, + { url = "https://files.pythonhosted.org/packages/76/f0/e602ae0a1b139a7826e5189b93d91902564def06d5006324fd2faf82c8fc/uuid_utils-0.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:9e311f908d2f842fca4c7dcebc4f10306b8089b204ef04cf6704b4332c9ff6ff", size = 173630, upload-time = "2026-07-09T13:49:03.529Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/024ebece265b387154115dc4f1d9727174ef82623069f4bec8b7ed7e73f7/uuid_utils-0.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:c351737e2e65497c7200ab4ffb8af97e9f48be6488309abdd265fe08d66ee92f", size = 173214, upload-time = "2026-07-09T13:49:04.836Z" }, + { url = "https://files.pythonhosted.org/packages/56/44/e2fd3fdf356e1b55d2acf1b956b4f3f29ffb215a99c387eba04b1c5fba66/uuid_utils-0.17.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:673d89cc434cc9b97a0b4cf61272f6fca70a81f64eb0afbface2a0d9f77f06cd", size = 562232, upload-time = "2026-07-09T13:49:06.201Z" }, + { url = "https://files.pythonhosted.org/packages/19/28/65e0980d668a6d44e699f59d1acf43d6b5d4893592c115ce7c680bb4dfa1/uuid_utils-0.17.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:387cf7437c94ddec08651a0f1081381299c7075bc48a6251d8922bf39973378a", size = 287858, upload-time = "2026-07-09T13:49:07.45Z" }, + { url = "https://files.pythonhosted.org/packages/8f/8d/5e97bcebc90fb6a10f98af3dc1ba552e04183aba59e2edc0b9cf486dd998/uuid_utils-0.17.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:220b52746d99e11964badac3c0869016e0c24bafb70a7dd5c2c072a6be3da9cc", size = 321587, upload-time = "2026-07-09T13:49:09.489Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d7/88b2a2370cc3d455ba0515fb6f5c8f7ac0c0f55a86801b6e56a432f22c17/uuid_utils-0.17.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0ab4a66e7a035ad6625cfc1fbdb34f5c2d25a80ae1ef4bfee458ea2036333c6d", size = 328964, upload-time = "2026-07-09T13:49:11.292Z" }, + { url = "https://files.pythonhosted.org/packages/bd/0f/181c5da673953dfc0958cb4fb3a4984a9098673ddb05cac68e994bc8511b/uuid_utils-0.17.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5641071337eb11d61a001ea08793bf72216f3241f0a433ed2764804b2a3e3cc7", size = 442909, upload-time = "2026-07-09T13:49:12.644Z" }, + { url = "https://files.pythonhosted.org/packages/ec/38/5c5e665af542884a8fd3c61725c38453239e13940326b5b70f3ef8881a97/uuid_utils-0.17.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9082e709014946b1f6e96ae6ecd93652efca2d2a6a3ab67dbe151c8b4bf193a4", size = 323076, upload-time = "2026-07-09T13:49:13.897Z" }, + { url = "https://files.pythonhosted.org/packages/f5/35/7de97de18cbf226c2a4f2104ad15e56ca4491717c81c0b71795c0c585b4e/uuid_utils-0.17.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1fd6f0e8a162dc0e9255b6aebe3cd175e76c33202f1bf39da9e6294b93db0099", size = 347360, upload-time = "2026-07-09T13:49:15.237Z" }, + { url = "https://files.pythonhosted.org/packages/26/a1/9915d5dd59fdd1957ded5d188c0ea0b9db5a1d84d42c8d8828a7b83b366e/uuid_utils-0.17.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d63010803d7c368963bbe6f7ec379593e76dd581d7db0f29118d88713c9e0354", size = 499267, upload-time = "2026-07-09T13:49:16.774Z" }, + { url = "https://files.pythonhosted.org/packages/c0/05/88108405262ec850cea0f95733445d6873e5772af3292baabd9ef8457740/uuid_utils-0.17.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a46bedc273b6f58f11dee816ff74999625ef8d007890f411b7a4975bf1c89330", size = 604940, upload-time = "2026-07-09T13:49:18.147Z" }, + { url = "https://files.pythonhosted.org/packages/89/d5/6dbcd300de47cc443cff2656cd5327a385751213dcb2101cfee7388170b2/uuid_utils-0.17.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:405233a5f625b3d995648f4647fa6befa4567cf3f74e1f6b9837e16f7310f0e0", size = 564172, upload-time = "2026-07-09T13:49:19.593Z" }, + { url = "https://files.pythonhosted.org/packages/ab/94/e8057f2288a415fba8a978bca4b589f5cb6b91a028a5dc07a1775938b33f/uuid_utils-0.17.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b6c5d2d71e1f17329150ad9427d27f4a3f29a01792e7ecdc64a98ac5368fc4d5", size = 528533, upload-time = "2026-07-09T13:49:21.075Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6b/31713148c77e48e62f51aa042a98a54a8be0396912ea5130f83f52ae722d/uuid_utils-0.17.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f7e9b8728ba07a3cb2f29d5aa1a266c2664eb8ef0fd43afa34627c92f7fac8f0", size = 99197, upload-time = "2026-07-09T13:49:22.351Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f3/ca6f6ac5428312df8ed632f6dd9f9e6aba23090471fcdeae53eab027e8b3/uuid_utils-0.17.0-cp314-cp314-win32.whl", hash = "sha256:58838921e377791ef22c64cc92141bfae030f43651ff9272f0f28a208a9e6a5a", size = 169540, upload-time = "2026-07-09T13:49:23.563Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cd/7ede0db66411fa09817d79b680f7454ea9bee2d374e1922e4efd065760a3/uuid_utils-0.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:42275ebd0e8e74e32cdbfb8bd88fc99576567d51d54a508020611fd8f4f463a0", size = 175984, upload-time = "2026-07-09T13:49:24.703Z" }, + { url = "https://files.pythonhosted.org/packages/f0/81/533b5f80cd4918c0693f4e1b7b90ceb1caa45f4266ae8b528135d7ecca5d/uuid_utils-0.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:b5d11cccba076a32321ef1380dea956821f0b51794ef59df64e58fb1cd543aae", size = 174749, upload-time = "2026-07-09T13:49:25.886Z" }, + { url = "https://files.pythonhosted.org/packages/a0/13/f400ac39d06fd8be5b099c09e41bb975205926722a3e8d53348817cb7ff9/uuid_utils-0.17.0-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:fae8b282f0cb22a5de222999f7723f4e5ec04f6fcdf4aaef879b5b36625ae2b0", size = 562610, upload-time = "2026-07-09T13:49:27.374Z" }, + { url = "https://files.pythonhosted.org/packages/03/8c/c71c8312304c56f6d0bcba87cd402fa79bec35d18ffc8c41954196ca68e5/uuid_utils-0.17.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:967955620df45e6cffe2e9950cb9903cb455649396f896b26b04363a91a5054b", size = 289473, upload-time = "2026-07-09T13:49:28.989Z" }, + { url = "https://files.pythonhosted.org/packages/bb/cd/522117e2e5184ca1d4f0f85ee833e9e21bd8c6b99eff8a4d1a8e5a194e33/uuid_utils-0.17.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:375cde148430d60a4a07c03abaa0774c4fddfdd90de99b4ba02f24088bc9d750", size = 321600, upload-time = "2026-07-09T13:49:30.4Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f4/0d81f9bd346fc717bc561c08fa6457e0328966eb76e536b938fe77d56459/uuid_utils-0.17.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:975c17da26c5b9d46c336b03c52a057ac28378d6f9d98b58d32a038589bb3912", size = 329569, upload-time = "2026-07-09T13:49:31.732Z" }, + { url = "https://files.pythonhosted.org/packages/5e/41/26e1363f36a94c9e8ec2dd21d5f63088d3e7c723adbb12dcc8fdc77be417/uuid_utils-0.17.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3150d836290c88f1d26eb59c4db280d87417dd3bfaadd2889c77416c8f0ff6fa", size = 442051, upload-time = "2026-07-09T13:49:33.024Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a7/2c1ed1b34d7df7fdcc11c28fd26d94d44843b37d9af2435ff9fd8abdbc08/uuid_utils-0.17.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9472a8de37faf8bd216c628e0e68c8f6bef730d3ba0a5060f3b0fa460c992ac2", size = 324372, upload-time = "2026-07-09T13:49:34.554Z" }, + { url = "https://files.pythonhosted.org/packages/78/bf/328d3c6bb22c496944a1b3b732207d71aa6964eb604e5e3b9dcb91ed0a00/uuid_utils-0.17.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d27c531edb8d1f38ca2eddaa1fa24913a460aeb721f2efd4ef42a124ce94e354", size = 348548, upload-time = "2026-07-09T13:49:35.898Z" }, + { url = "https://files.pythonhosted.org/packages/3e/76/a07de5cb7b90582fdbbc830fd19be129cbbb9897cfe239fef469d7bd2d09/uuid_utils-0.17.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5670c52a438e21483ce715776144914a4e2a2a5c62d9dee15f8a3e90cf128ae6", size = 498985, upload-time = "2026-07-09T13:49:37.142Z" }, + { url = "https://files.pythonhosted.org/packages/f4/62/9966e46ae34fcec6b06119631fb3c09705ea78835035ce3a82d3348eb61a/uuid_utils-0.17.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:6f29689a76fe7a49cbd629a794d0ec1eab48814e323a00a146a741b0195bde68", size = 605183, upload-time = "2026-07-09T13:49:38.648Z" }, + { url = "https://files.pythonhosted.org/packages/d7/4e/bb962ba0fe31e903b199f22cf4c1a6cba35a8987aef526d287277ab8ca8b/uuid_utils-0.17.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4441600447d340ae103a353f01dbcd22ff680e5ee1a22988efe8d7b791d8fdb3", size = 565412, upload-time = "2026-07-09T13:49:40.115Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9e/122adfeeeae8a84ccfd43bce627b104d12a2180a93bffd2c0e1b54dad7a6/uuid_utils-0.17.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7b04935a79c03c41ad08d0a5f390aac968bfb561f1268897bc5b0f077971efd", size = 529885, upload-time = "2026-07-09T13:49:41.513Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4f/257304dded339dc35fc9bf35722ac68fd4fdb930f255b8f7bccdf74ebba9/uuid_utils-0.17.0-cp314-cp314t-win32.whl", hash = "sha256:239d8a281fe10bae33205b5d43185834d556b18434e0a113b5dc1dfb2fd97e91", size = 169472, upload-time = "2026-07-09T13:49:42.871Z" }, + { url = "https://files.pythonhosted.org/packages/35/c8/e78c06db7e9ce317ce7b8759ff2058333eac75caa8c22b75f0059589c9be/uuid_utils-0.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e288a06cbbbcd01b44386e767985c9e21d2ad9bf59829aa7058d9a2a494804ab", size = 176271, upload-time = "2026-07-09T13:49:44.105Z" }, + { url = "https://files.pythonhosted.org/packages/a7/11/bd1c70e1ad3301163cebe66c8d26de26e6814d52f642a849448bd2833626/uuid_utils-0.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1776a80d16369999b21627028cc5dbce819be83e1e079fdd7a51b587d2916db9", size = 175004, upload-time = "2026-07-09T13:49:45.591Z" }, +] + [[package]] name = "uvicorn" version = "0.41.0" @@ -4556,47 +4882,33 @@ wheels = [ [[package]] name = "websockets" -version = "16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, - { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, - { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, - { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, - { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, - { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, - { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, - { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, - { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, - { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, - { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, - { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, - { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, - { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, - { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, - { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, - { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, - { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, - { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, - { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, - { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, - { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] [[package]] @@ -4683,6 +4995,119 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/34/98a2f52245f4d47be93b580dae5f9861ef58977d73a79eb47c58f1ad1f3a/xmltodict-1.0.4-py3-none-any.whl", hash = "sha256:a4a00d300b0e1c59fc2bfccb53d7b2e88c32f200df138a0dd2229f842497026a", size = 13580, upload-time = "2026-02-22T02:21:21.039Z" }, ] +[[package]] +name = "xxhash" +version = "3.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/63/71aa56b151a1b28770037a61bd4e461c2619cfc8866a4fcaf1548605e325/xxhash-3.8.1.tar.gz", hash = "sha256:b0de4bf3aa66363552d52c6a89003c479911f12098cd48a53d44a0f7a25f7c46", size = 86223, upload-time = "2026-07-06T10:49:58.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/91/f65c34a7aa7b4e7cf4854f8e6ef3f7ee32ceac41d4f008da0780db0612f6/xxhash-3.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e6e49370822c1f4d8d90e678b06dbcb08b51a026a7c4b55479e7d467f2e813bc", size = 34680, upload-time = "2026-07-06T10:44:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/57/04/b10a245a4c09a9cfa88f8e9ae755029413ad1ac17047f9a61906e5ae0799/xxhash-3.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:220d68130f83f7cc86d6edfdeab176adc73d7200bf3a8ec10c629e8cf605c215", size = 32397, upload-time = "2026-07-06T10:44:42.196Z" }, + { url = "https://files.pythonhosted.org/packages/3a/75/45ab795b5945b6388583bd75202106af505537935566c15a1577797a0e08/xxhash-3.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d365ee1892c1fa803536f8c6ce21d24b29c9718ec75eb856095c07830f8c478", size = 220549, upload-time = "2026-07-06T10:44:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/13/44/5ba2bd0a14ddf4193fc7d8ec29625f659f22c06d60b28f04bf46305d8330/xxhash-3.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:852bfe059720632e2f16a6a4745e41d20937b2bf2a42a401e2412046bb6971cc", size = 241186, upload-time = "2026-07-06T10:44:45.534Z" }, + { url = "https://files.pythonhosted.org/packages/23/32/c4147def4d1e4538b906f82731e0ba23424377fc50a7cddd03cd284c8f63/xxhash-3.8.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2f8c25a7061d952de589bd0ea0eaadee32378ff83dd6a677b267f9cd86f401f8", size = 264852, upload-time = "2026-07-06T10:44:47.199Z" }, + { url = "https://files.pythonhosted.org/packages/6c/bd/71ed14f4f0318bb7fd7b2ec51999413487fa8da8d41208e84d50d1ef0f98/xxhash-3.8.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:868a8dcaff1a84ba78038e1cef14fc88ccf84d9b4d12ea604696e0693296aa56", size = 242663, upload-time = "2026-07-06T10:44:48.846Z" }, + { url = "https://files.pythonhosted.org/packages/91/09/70af22c565a8473b3f2ae73f88e7721af281bc4a575236dbd1970c9f76f6/xxhash-3.8.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6536d8677d2fff7e64cd0b98b976df9de7aee0e69590044c2af5f51b76b7a170", size = 473510, upload-time = "2026-07-06T10:44:50.695Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/34db781c8f0cf99c544ca1f2bc2e5bf55426e1eb4ca6de8ea5da56a9f352/xxhash-3.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82c0cedd280eab2e8291270e6c04894dbc096f8159a39dcf1807429f026ca3cc", size = 220469, upload-time = "2026-07-06T10:44:52.422Z" }, + { url = "https://files.pythonhosted.org/packages/93/5f/9a184f615fa5a4dce30c01534f62946ce5a11ce40f73785cbd356ccabaa9/xxhash-3.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daa86e4b68221d38e669bb236ba112d0335353829fb627c82e5909e4bbe8694c", size = 310290, upload-time = "2026-07-06T10:44:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/a9/dc/9b9a9789011ee153723a5eb9e7dd7fcbae2ba9b3fe7a729249ca7c252056/xxhash-3.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2bc7113e6f2b6b3922dd61796ca9f36af09da3773898e7003038dc992fc83b8d", size = 238173, upload-time = "2026-07-06T10:44:55.693Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4d/71c6005ada9dcb608a4e1902e8475ecadb5f3fbfa04e1e244d276a2d0c43/xxhash-3.8.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5eed32dad81d6ba8e62dc7b9ffa0500199385d7810a8dd9d4eafaceb8c6e20bb", size = 269026, upload-time = "2026-07-06T10:44:57.424Z" }, + { url = "https://files.pythonhosted.org/packages/2f/87/d6c036ba25dfbd9c8633be5aa86fc9474bbb9e2c68212a841d090abe7344/xxhash-3.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:83697b0ea1f10e7f5d8b26a4906fa851393c61546c63839643a2b7fe2d868061", size = 224970, upload-time = "2026-07-06T10:44:59.085Z" }, + { url = "https://files.pythonhosted.org/packages/48/62/4c1f035a41c5752aa05e195b6c904c07b94fe9061a16de61e72a6e6b135f/xxhash-3.8.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:36fc69160465ae75c6ec4ac9f781bb2aa16ae7ff869e73c26fee85fbb11b9887", size = 240820, upload-time = "2026-07-06T10:45:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/da/14/d39d565069b87e86d21a2af2a31d04db79249d25aa8d5b62959056a89857/xxhash-3.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:445e0f5a31f2f3546ae0895d4811e159518cdc9d824c11419898d40cfadb677e", size = 300619, upload-time = "2026-07-06T10:45:02.716Z" }, + { url = "https://files.pythonhosted.org/packages/13/22/75467acc887edc8cf71c97ab1708feb3df7a88bda589b9f399765c6387d2/xxhash-3.8.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:dfe0580fbfd5e4af87d0cc52d2044f155d55ebd8c8a93568758a2ea7d8e15975", size = 443267, upload-time = "2026-07-06T10:45:04.653Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b6/1da3baa5fa6ef705e3425fddd382be7dfc4dfba2686df90a20f16e9c7b1b/xxhash-3.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:095e1323fa108be1292c54c86da3ef3c7a7dc015b105a52133973bc07a6ad11a", size = 217338, upload-time = "2026-07-06T10:45:06.304Z" }, + { url = "https://files.pythonhosted.org/packages/78/dd/b5295a9f97484e7a1c2b283a742ca45e3104991c55a1ef670dde161829ba/xxhash-3.8.1-cp312-cp312-win32.whl", hash = "sha256:bf28f55e427e0483acb1f666bd0d869b6d5e5a716680c216ad7befe3d4cfba2e", size = 31970, upload-time = "2026-07-06T10:45:07.823Z" }, + { url = "https://files.pythonhosted.org/packages/ec/31/3fa0b807d7e21515cd975e7fe5c039d52ac3e9401a96d6ad68dae6305215/xxhash-3.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:2256e80e4960ee282f63428adb349cb7f8bd8efe4db770d88eb815f4b9860724", size = 32741, upload-time = "2026-07-06T10:45:09.42Z" }, + { url = "https://files.pythonhosted.org/packages/b8/05/86feada74e239600e6875aa507afb40482a89b92700aa74a92da83bdcb77/xxhash-3.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:9df56e6df96a60590935e22373041cccc91fd55858763dcffb55bf63b3a2b396", size = 29234, upload-time = "2026-07-06T10:45:10.809Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8c/446bb782cd0d27007a917b5569a08dd73219c3e8d6e459014db104b27bdb/xxhash-3.8.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:3c682fcd96eb4bf64be32a4d95f96107e1588005831bd8a741b324fdda01b913", size = 38562, upload-time = "2026-07-06T10:45:12.425Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ec/c0c45627eaa6be7a5d6117423adf8f7a15b17ee74b4b17072cca5959a225/xxhash-3.8.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:036a024d8b9c01f70782e09ed98d532e76fd23f950ae7154bd950fe94e90ebec", size = 36656, upload-time = "2026-07-06T10:45:13.932Z" }, + { url = "https://files.pythonhosted.org/packages/f6/94/8324c04cc7597154caaeba6c094e01fbd2e7601d01e7a13eea9f5420e77b/xxhash-3.8.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d6a5c0bce213b23b0166fe0d35bcbbe23ce4b968f257cc7eb6fd57cb8e1e6297", size = 31169, upload-time = "2026-07-06T10:45:15.687Z" }, + { url = "https://files.pythonhosted.org/packages/40/a4/beb6bb26e1184e126dbe7a5682330214ef54dcfbf882078aa9f4b5428d42/xxhash-3.8.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5177aa44eddaa97c6ef0cc00c6d540edb64d51781d2f8fb941612ec61a92c9ed", size = 32177, upload-time = "2026-07-06T10:45:17.035Z" }, + { url = "https://files.pythonhosted.org/packages/56/0f/fc4c92a5a528f839b34b6419b2e53c8597f2a629d5a1f5d721f65bfa1fd6/xxhash-3.8.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7801b7223db017b9c0c9ccf37e44524edb35a1544a1c032add22c061c6af0276", size = 34642, upload-time = "2026-07-06T10:45:18.39Z" }, + { url = "https://files.pythonhosted.org/packages/d4/58/edbfb141d4000767ac6a9694f8ac0763e2c2e983e65c9e31620ba56e2667/xxhash-3.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9e80238259655bf69d7bcd08226a970d7f42605f3157786bfa76dd13472d7fa0", size = 34684, upload-time = "2026-07-06T10:45:20.033Z" }, + { url = "https://files.pythonhosted.org/packages/07/3f/5072f1f0f5714186f0ac2a0b5a4929ce30d4b845e94886b6c01b6ebda0be/xxhash-3.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bcab50a389cc04d87f90092af78a6adba2ab3deca63175a3344ca83514045315", size = 32401, upload-time = "2026-07-06T10:45:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/49/c7/802ea2f9c2ed59219934d6d65c470d502b1788043eae277a52af8658bda6/xxhash-3.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a2489d3a776fa380cb8e71f54c7fda268a9baf3de9b1395093fd280f95735907", size = 220617, upload-time = "2026-07-06T10:45:23.234Z" }, + { url = "https://files.pythonhosted.org/packages/99/a8/e10488efd31fcb13fcd6acbc6e788f10c6f8e3a0cc4ae3eb89dc19c55a12/xxhash-3.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32ab1e5432690276e71192be7401b55f96db2d0eedea5d44eb1f164505669cc0", size = 241295, upload-time = "2026-07-06T10:45:25.364Z" }, + { url = "https://files.pythonhosted.org/packages/18/cc/14180b17d44892a631f8ae7323c30bfbb1328efc8209e528a480293528ac/xxhash-3.8.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b30e01a0b97a4bc3f519a4d7a82da3dc53251fb0de5eeea8660dcd4ff094c0c2", size = 264688, upload-time = "2026-07-06T10:45:27.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/72/a14019d0c5f6c41ee407a503036ae32787c91325ca218a96a9b5627be651/xxhash-3.8.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f44275ddb0978b67a58a951501903f04d49335a91f7681c9ce122ecb8ccb329", size = 242740, upload-time = "2026-07-06T10:45:28.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/08/92550e556c6fcfcb96c6a336945eb53a431ed43120ed749636debb16c5cf/xxhash-3.8.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3b87cbd974512c0c5fc7b469c36b2cdc9ee6d76e4ec78bccb2c7184611c49b0", size = 473599, upload-time = "2026-07-06T10:45:30.524Z" }, + { url = "https://files.pythonhosted.org/packages/29/83/e361d3c1acd1b21e1d489616de6fa4aaf843365d8179f612e3743eac20a9/xxhash-3.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98ee81b4b7f3023c9cb04a78cc67610baffcb5812d92f2096cb5a5efc6f19437", size = 220559, upload-time = "2026-07-06T10:45:32.979Z" }, + { url = "https://files.pythonhosted.org/packages/05/01/006a4243c2c2a6831827f9999f6d1c23feeef100eb023c1f886022a00bf3/xxhash-3.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2666f059a1588a99267e33605365ed89cea92f424b3522806a9f4bd8ad2e3d62", size = 310383, upload-time = "2026-07-06T10:45:35.875Z" }, + { url = "https://files.pythonhosted.org/packages/d8/20/af388e8bf9f9a0f89eeef7d2a1935d176ee1c20bc6adeda05035879379cf/xxhash-3.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0093cf7eeb91b84776e8742113afa4bdf47533d36cf719179aaaf1f56f6f8bf", size = 238228, upload-time = "2026-07-06T10:45:38.02Z" }, + { url = "https://files.pythonhosted.org/packages/63/6b/4666579a87eebd1744663c404297355fa0658617b015cedfa58810ee7036/xxhash-3.8.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3a800912a2e5e975d4128969d645c4a2a80aa886ccd6c9b1c6f44529e327e8cf", size = 269137, upload-time = "2026-07-06T10:45:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/de/d3/e963a8a46f900a137d91b02144d8ea07a8f812971b138204a3b2f8b8e55c/xxhash-3.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0fe37f72a207223d22a4eddc3149d4298993385aa9daef25c039246ca5a309f3", size = 225068, upload-time = "2026-07-06T10:45:41.718Z" }, + { url = "https://files.pythonhosted.org/packages/aa/80/9d181dbcde4b0fe48375f48833a5832d4b8cd2b349b15110c92ee472d874/xxhash-3.8.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5db43f249b4be9f99ef4b967863f37094fb40e67effafb78ba4f0356b6396104", size = 240874, upload-time = "2026-07-06T10:45:43.414Z" }, + { url = "https://files.pythonhosted.org/packages/39/15/ce3ab5a1cd27ead25a5196e55a7284220f6ad6e316da494ffd900b2b600f/xxhash-3.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c4ed42965c2cd9081f011be22f69d0e65d3b6165fe7734072fd0c232840bbd4e", size = 300702, upload-time = "2026-07-06T10:45:45.135Z" }, + { url = "https://files.pythonhosted.org/packages/96/c0/2281a8ab5f2a62dbf57a23c58a01ccc1d98abf40f71193c8a81f59e759b5/xxhash-3.8.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3557bec8fcb11738a8920eeb68974bc76b75262f6947998d3147954ce0a4b893", size = 443351, upload-time = "2026-07-06T10:45:47.188Z" }, + { url = "https://files.pythonhosted.org/packages/81/2e/071a58c1a53a52d4f7a3aa0987be0c396dffd40da8204805fe1b130a81f4/xxhash-3.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:00de40f3b42240db23a82a5c682b55d7263d84a26a953240c1aee463409660e3", size = 217396, upload-time = "2026-07-06T10:45:48.925Z" }, + { url = "https://files.pythonhosted.org/packages/68/44/36ab58134badd9d3433fc7b53c4ca8d113d8e807782885628640f8297a4d/xxhash-3.8.1-cp313-cp313-win32.whl", hash = "sha256:b5196cc2574cfec572a5f3fb7cfa5ade27305ae3d06516a082132441aff4c83a", size = 31974, upload-time = "2026-07-06T10:45:50.591Z" }, + { url = "https://files.pythonhosted.org/packages/96/2a/2a0b84798448e766f7b89ceed073cb0cb5a43fc9ebbacbdea74a38de18e3/xxhash-3.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:538f5f865df6cd8c32dd63158a0e5b4f5dd08d732a7da8b7228a5a0776c8ce55", size = 32739, upload-time = "2026-07-06T10:45:52.221Z" }, + { url = "https://files.pythonhosted.org/packages/d4/60/bb51dbf7c363ff88a7cbd50b7959718219577ef44d7cf255929ffc4a2194/xxhash-3.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:a6617f30641ba0d8baa1635fbefb1dffc5165ec36d26921bd5cee13497cd937a", size = 29239, upload-time = "2026-07-06T10:45:53.714Z" }, + { url = "https://files.pythonhosted.org/packages/56/d3/827ca123c2ee5443a6aaed3c5dd199237dc2f010e2bebd7ec09ef36f3a5f/xxhash-3.8.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:bfcd82852c62a60e314670a9602de354c4460f8adad916e2e42a20860c7870bc", size = 34964, upload-time = "2026-07-06T10:45:55.535Z" }, + { url = "https://files.pythonhosted.org/packages/05/67/67ae2a3ccdeb8b8ef025d35aee9edd1d26c3abe5051d47da9286232afbf8/xxhash-3.8.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:08ea2081f5e88615fec8622a9f87fbe21b8ea58d88cfc02163ca11026ee62a92", size = 32697, upload-time = "2026-07-06T10:45:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/38/5a/3d3994346e1f45493679cb5c1ffc2bf454e410e9d1e8a662d253becee91e/xxhash-3.8.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2e32855b6f9e5b18f449e59d45e3d5778bdeb660632ef2693cca267a11246c75", size = 225954, upload-time = "2026-07-06T10:45:58.897Z" }, + { url = "https://files.pythonhosted.org/packages/3f/2c/53169270309b7cd8e05504e07fe123bac053b89d00ac63617faacf0a2ec0/xxhash-3.8.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6e088bd7870775624256a0d84c2a6714afd223b2eeb56b0ca58398e52a32fda", size = 249776, upload-time = "2026-07-06T10:46:00.977Z" }, + { url = "https://files.pythonhosted.org/packages/70/e0/5c551d8d592f944506f7c5185e210255c15e672a3c6008c156a1bd9b775e/xxhash-3.8.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:72eb5ae575cc7ae2b23f6f8064a8b10f638c7149819ae9cc6d20ebd4d37a1629", size = 274776, upload-time = "2026-07-06T10:46:02.869Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/d3a762270cee2d7bcd0e25e28c623e5f3f5c0dc637b66e3e47dd5b0bb3f0/xxhash-3.8.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d0b48cdf690a64cedf7258c3dc9506cc41fc86edd7739c40e3098952265dc068", size = 252056, upload-time = "2026-07-06T10:46:04.688Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/b78e4373b2cb6d1c42af60ea2d7e9146ad0710b239ac7f706d5d31d5bb98/xxhash-3.8.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb9e256a357dfcede7818c6d34e70db2d6b664394803d1de4b6984d2de76c0f1", size = 482108, upload-time = "2026-07-06T10:46:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/e6/0d/642d923336ea61a15f8ce64fc7e078729e6e06c3a026e517fa79b2c23b7a/xxhash-3.8.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51f71a6e2ad071e70c937e41fcb6c19f82c3f9f49831eba850ed4a106ffbb647", size = 226739, upload-time = "2026-07-06T10:46:08.598Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0a/a37d6da6427d45a8d23e3ee3a0ca9c9d4a90364849c6637fe2963a755f9b/xxhash-3.8.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4a6443968c4e8dc69967e12776776a5952c119cc1bd94168ad1c5ad667c2be1", size = 319658, upload-time = "2026-07-06T10:46:10.504Z" }, + { url = "https://files.pythonhosted.org/packages/4a/51/ebbd40da8a3f1bc53b4b7a9a87f8e28bd95c5f21bc14b8a57860cf367d1b/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:714503083a1f2065c9ad15340dd49ac8a8e948a505a705ffa1750cb951519113", size = 246059, upload-time = "2026-07-06T10:46:12.634Z" }, + { url = "https://files.pythonhosted.org/packages/24/4c/d9014030147e1f0bb26e7da47aa240dd9ec61c763c573e558111d869f8e1/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:77f74e45a1e5574bbbf80181c8027b3a4c65c2248fffbd557bd596fff13102f9", size = 275535, upload-time = "2026-07-06T10:46:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/caee2db41fadcd5a25aa4323213f9afec5a8586d4e419241e3d659362bd7/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:4e0e1b0fb0259c1b75d1251ac0bb4d7ab675d36f7a6bf4ba6aa630dae94f9ffa", size = 231292, upload-time = "2026-07-06T10:46:16.452Z" }, + { url = "https://files.pythonhosted.org/packages/0b/60/f52f08bcdc904c4514ea5c25caa19e9f3214144434a6ff96dc82dc1cbddd/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:10e4393ec33633c2f05ad01869e546ad080b1a18f2650503731f153774608b31", size = 250490, upload-time = "2026-07-06T10:46:18.318Z" }, + { url = "https://files.pythonhosted.org/packages/24/a0/94dc7ae310838f250669c6ad7168e6d6fca17d49dac1053f06dc232c4a56/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b3ba794c3d885803db6c3116686923f1ec13bc86e621e169a375282b63ea1cc6", size = 309861, upload-time = "2026-07-06T10:46:20.503Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/adeead7d0eb28cdfc2832544ea639ffbc6749ccde47a8e228d667459182e/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:57189a69c0891e4818853feaa521c972d22c880a001453addea015f48e3c3398", size = 448739, upload-time = "2026-07-06T10:46:22.79Z" }, + { url = "https://files.pythonhosted.org/packages/04/a4/22ec0e07db57d901c9298ae98aa3cf2be45bafded6f07c13131e85b89032/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d59e71153fe9ff85648d00e18649b07e9b22c797291abb7e27274fa06df8b838", size = 223657, upload-time = "2026-07-06T10:46:24.831Z" }, + { url = "https://files.pythonhosted.org/packages/94/32/8a9531f37b59e5a013003db7cb7414baf4ce7e0e1268e0d5947cd3d6a2df/xxhash-3.8.1-cp313-cp313t-win32.whl", hash = "sha256:5b96f0024e9840f449bd91b2d005c921a4b666055a0d1b6492463799f32aae22", size = 32377, upload-time = "2026-07-06T10:46:26.86Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/2ca45fd7f671de5f81fc297ef1c95080b40c86ec6be0cc6034b8f7707ac8/xxhash-3.8.1-cp313-cp313t-win_amd64.whl", hash = "sha256:37d5a56c36dcc0b9a87b814cd992598d33863ff683749de6c86081f278d5e629", size = 33274, upload-time = "2026-07-06T10:46:28.39Z" }, + { url = "https://files.pythonhosted.org/packages/5a/54/20d7163463ddb6438b73a427d1655a77a502cf9b9b0c3ada3599629d9c0a/xxhash-3.8.1-cp313-cp313t-win_arm64.whl", hash = "sha256:6696c8752aded28ff3b16f33ef28ce28fb5d209b80c206746f943199fcf5fd65", size = 29375, upload-time = "2026-07-06T10:46:29.962Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8b/df2ba04f22a6cd6b39f96a6577329a8471a55c90ef8d8e2f7c102363613f/xxhash-3.8.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:9db455cb649dcfe4504d6d68a6d83a7315a99a3ca59871dc3ff840671f99adba", size = 38430, upload-time = "2026-07-06T10:46:31.496Z" }, + { url = "https://files.pythonhosted.org/packages/b2/4f/6a059e8ad3ca8deedc91dfe335b211204900895152212c03ebbe721de68b/xxhash-3.8.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:affb37f152e55b5e4494bb9d0107f7bb08515c6704fbed82d9f61214d74adc17", size = 36558, upload-time = "2026-07-06T10:46:33.078Z" }, + { url = "https://files.pythonhosted.org/packages/cb/95/40be178205acce092ae418feb20ac737b32a02c7b864926ed0717354c9f8/xxhash-3.8.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:460261045936975193bfd20549a0de1cd52a33b405cbb972f0d80940c42266cd", size = 31181, upload-time = "2026-07-06T10:46:34.793Z" }, + { url = "https://files.pythonhosted.org/packages/3f/89/2da4dbf051bafa156c0e3f12012db2b0ac3b84ff37ca1f021f6bfffcdfbb/xxhash-3.8.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:38c887aedb696ef8bca19983206d270848558cfae4a91afa6a2fb05dde58ffc5", size = 32192, upload-time = "2026-07-06T10:46:36.393Z" }, + { url = "https://files.pythonhosted.org/packages/7c/4e/e000bbae3566bc8e0be771a8a0f294aa99075e3f0bc4ef43922ebffdebc8/xxhash-3.8.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:594131ce1aad18db3689781f806db1b065cdaa04f4df36b4c038d2013aefd0bf", size = 34691, upload-time = "2026-07-06T10:46:38.1Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4a/ea954aacc7d1c8711880ac2b55da94429a9b4296b151c4fc0966549ca1ee/xxhash-3.8.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:78c794b643d214f1522e7a288bcf5a2de120d26cd170516749a4009dc92722c9", size = 34807, upload-time = "2026-07-06T10:46:39.647Z" }, + { url = "https://files.pythonhosted.org/packages/ca/29/df598e738ff37558ac627264deb2e560902d9bf7f46d3bd5175c9eee593e/xxhash-3.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af0c9fedc4a2c24e8664953882fe8185f3790b8338c9c700f76f5ad660817711", size = 32410, upload-time = "2026-07-06T10:46:41.359Z" }, + { url = "https://files.pythonhosted.org/packages/59/9c/81ab40e7d33ada0b3df5d1bc884894d15dbf4f805cd645b685e4606bb8e0/xxhash-3.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:115772daeb71b2f3b9381177017f53e6cf3f3439c840737fdabd21aba6e54920", size = 220564, upload-time = "2026-07-06T10:46:43.463Z" }, + { url = "https://files.pythonhosted.org/packages/fd/6f/62ae6f5c8606320a0e2a41c2dc8c6d91cc5d63d0f84dd9582e9543779dd8/xxhash-3.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:000435984a0469b0f822fe76f35bddea0f96a4d6521b3339a60a6428cdee1edc", size = 241462, upload-time = "2026-07-06T10:46:45.509Z" }, + { url = "https://files.pythonhosted.org/packages/15/a1/9c3a0ec6cb524396f551eddd102a76690a795494eb9784fc67542b0daa37/xxhash-3.8.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2f1c68394818e0595569c2ff3cbc1e6d5a36a434e796f5c526b987b80c8a8c62", size = 264491, upload-time = "2026-07-06T10:46:47.655Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/700a4674e4308eb59d2fdb973977e82eae231bea5044753fee5c9eec0e0c/xxhash-3.8.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46b39976d008e2a845758650f0ff7136bca004f40da0c8798bd37ac37860154f", size = 242905, upload-time = "2026-07-06T10:46:49.857Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8a/72d9874375c8d4cbc64a8cd1d659d5695a8765c3db82efa82dc5bd9f14d0/xxhash-3.8.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d5006c65ec507a333479e76e00e2c368781f16c24ededa764763956b32a0e93e", size = 473873, upload-time = "2026-07-06T10:46:51.953Z" }, + { url = "https://files.pythonhosted.org/packages/03/f0/6db07590ed7e0a77f186ef0bcea8d52553bf1ba57833e09467a2411f0f2d/xxhash-3.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31a2649bcf1fe97cf11c79848d761df33ac46b3896942d31b640557b486ff6b", size = 220765, upload-time = "2026-07-06T10:46:55.41Z" }, + { url = "https://files.pythonhosted.org/packages/8f/10/00d12d8b8beabbf49a8bbc626fb9f40445145a8887eb41a6acfb69149ac4/xxhash-3.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f759eed402448c2bdbb492e4fba1f20668ffe29688605ea61f0f67f9e4e386d", size = 310478, upload-time = "2026-07-06T10:46:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f9/12a82394eefb0f185d15a7f7b9f627c61c475a72dd83718436a5b84b42ac/xxhash-3.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5f97ecfede10d5b2870383620e2d25c8561e217c7bf9081073802b54248d2b", size = 238393, upload-time = "2026-07-06T10:46:59.87Z" }, + { url = "https://files.pythonhosted.org/packages/20/f3/53f963e320b9ce678337aa7273f39ce692ded8b99e3d22a866ec722159ab/xxhash-3.8.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1da930bbcac3e8fbe2191850e2abb57977a99348c12c4b385e1058ac1b0a9ecc", size = 268704, upload-time = "2026-07-06T10:47:01.806Z" }, + { url = "https://files.pythonhosted.org/packages/0a/50/5b5badbd87c82d9f9b5f58ac74a3f29ef08f6fc387b324b8fd482450b862/xxhash-3.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:747476436f6891b9773374ce8d48edcc8b12cb5b61b67c6fb6289633747d088f", size = 225015, upload-time = "2026-07-06T10:47:03.784Z" }, + { url = "https://files.pythonhosted.org/packages/30/93/3ca68265afe7b4e69435e08a7b6a1d9d0f2a071e889da1f8041ed00fe878/xxhash-3.8.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef09bbc2519a93cd0f95f2ceb5f7b85919dffea643278e02362bf40e3c4bed1", size = 240951, upload-time = "2026-07-06T10:47:05.816Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/27e19670c40f46b5e76e11f2f4713d21054804568425d870670e757172ad/xxhash-3.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a5eed9d41995a83f3332b4e3396abb7f433cac584222bd7e305b606d8353861e", size = 300751, upload-time = "2026-07-06T10:47:07.95Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fb/b33e27689959fe7ed2ae0b830af41560d65213943983afa9db3a8d481bce/xxhash-3.8.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:53f3ed9118397074ff63a79b66b7fec1c84c782eecde35c5bc94e420a971c231", size = 443480, upload-time = "2026-07-06T10:47:10Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/0e0d973be5fe280753ef02fbc89349492ad6e903bf1dcb870b668f94b662/xxhash-3.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d247b34bf433c92b41689318fd25d246313cab2275a6a47e2efac178b80d6efe", size = 217657, upload-time = "2026-07-06T10:47:12.196Z" }, + { url = "https://files.pythonhosted.org/packages/ad/68/c9e3ecef4a9a417d464cb5bd200aa12f73192dee677901b9e08e0ad0d1bb/xxhash-3.8.1-cp314-cp314-win32.whl", hash = "sha256:d58ce8b6cfa9c4d2f230557f69caf7c06369e318015d0b19485095bc2c5963ab", size = 32690, upload-time = "2026-07-06T10:47:14.204Z" }, + { url = "https://files.pythonhosted.org/packages/d7/99/e9e44588c0b62837bbec5ba7927816de0afa03406b1a0b6c7a7e1d1a30a0/xxhash-3.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:6cee733fe4ccb1737e0997135283c82341e5cfa9cf214b165f9087fb663aaf4f", size = 33460, upload-time = "2026-07-06T10:47:16.021Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/64f36d86380b3657ad9031967ab814f3ef31307174650853f69c18932ebc/xxhash-3.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:58346024d47e84f7d8b3e7f5d6faa1d58acbbe49a8771497872059f58c1d8ea5", size = 30092, upload-time = "2026-07-06T10:47:17.81Z" }, + { url = "https://files.pythonhosted.org/packages/92/cb/18b64bff88c58a0ca209dc533e63cf02d7ae5aa6b1b9a9fd14e81b5dbd60/xxhash-3.8.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:01cab782f8a0a05ecad2c63d7ef10f7ab475f660e0d6419d069418c14d88de7c", size = 35024, upload-time = "2026-07-06T10:47:19.821Z" }, + { url = "https://files.pythonhosted.org/packages/af/1d/72d8a70520e5dcddb472ea0486d299da3240745a10658290cd7b5690ede2/xxhash-3.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:717b12fdc51819833704e85e6926d76981ffa3f780ef92e33ebb8b26d46bb230", size = 32697, upload-time = "2026-07-06T10:47:21.649Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b8/e041f555903c56db3d0a731b3d72a6575d75e0ed868b1bd2e5176111ca44/xxhash-3.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ec55d80e9b8a519d742669e0b49e8ce9e6747be42bf3c138158b6543a9c8e489", size = 226044, upload-time = "2026-07-06T10:47:23.612Z" }, + { url = "https://files.pythonhosted.org/packages/3a/7e/5cdcf06bf6ec4b5d2ac073feb23432ec1d603fd438864cbd2c09c7cb45e1/xxhash-3.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98d8ac1129b4dd39098cffed94d1284aceb61c3aa396757ccc736ac392e4cee5", size = 249899, upload-time = "2026-07-06T10:47:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c0/eb7e059cb5e1dba11fd30d2fdf882f56e5a417a3eaa43669d43623767f45/xxhash-3.8.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3bc0fa90830df1e1277f33cc6e55de9990b83c0319fd8c7412866cfde38b025e", size = 274892, upload-time = "2026-07-06T10:47:27.931Z" }, + { url = "https://files.pythonhosted.org/packages/66/74/a600aaf7cd39957fd1510adeedb1749c1e7eb82bd632a1153d9c664c3135/xxhash-3.8.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c73b6f652f0745425aa6378319c331293b5341756262e9408ed3d45f183375e6", size = 252243, upload-time = "2026-07-06T10:47:30.288Z" }, + { url = "https://files.pythonhosted.org/packages/ad/04/78d88fa75a6763e5d09bf1b947a392a27988903381b219006f92f3c68fc8/xxhash-3.8.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6114692261eff4266386cdec0f7d87eee24e317ab397c218b7ae6a76b4c6339", size = 482191, upload-time = "2026-07-06T10:47:32.45Z" }, + { url = "https://files.pythonhosted.org/packages/7f/06/07a8aea1108d682de8791ce608cdf367d75ff4e7e57cd3c154bdc6f47b23/xxhash-3.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df57c0b161ec1b3ed0526a67b0db0914b557e86ee8aae51887aec941b261542", size = 226877, upload-time = "2026-07-06T10:47:34.705Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b5/86bade5618a524d2c06c4041aa2fe8e5749ce16e88afba60d67c1684a21f/xxhash-3.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9043877a917be88ccf230aa5667c1bd059bce80f4c2727e4defa1b29b7f48b08", size = 319794, upload-time = "2026-07-06T10:47:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/23/69/9b1a2b89b1621bb740fbcb7beb512f60f99480c1bdc680c0c90e1f56ff75/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:559e3cabe522231909f9de98ef06929edbd53782046bd21aae0c72db6f2a0775", size = 246202, upload-time = "2026-07-06T10:47:39.676Z" }, + { url = "https://files.pythonhosted.org/packages/08/ea/662ed6cb49f1d34078b6a3a3e0f3d29ff93fd7b5a03c0bc9ecfd9b2159c3/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:264710bd335016f303763ce1275c6486df30bb57c2245c91b224c983d7ac39b8", size = 275628, upload-time = "2026-07-06T10:47:41.99Z" }, + { url = "https://files.pythonhosted.org/packages/13/f5/49fc9e4c6728a5a3bd8fe639199d2fa67609b3a84f938aff6e8568dd3e4f/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e14800b9b10bb39d7a60ad4a310e403164d7b8988a27ae933d4e40618a44088e", size = 231390, upload-time = "2026-07-06T10:47:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/64/9d/3acaf8f599c0e0b30e910a3a11ba32929da53c86dc73c7c55fe6a010b4e9/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:ea6a3e734b0fd41b82784a400be946821900daebe610c050a5e0760838a34f99", size = 250600, upload-time = "2026-07-06T10:47:47.611Z" }, + { url = "https://files.pythonhosted.org/packages/23/64/8acab4c5ec60dbe664b5b9858fd44c2413b07e535b09556a0a5022e78aa6/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cf399fac542a1c7a4734a435b93df2c55e858c7d31abf6c1bdf46f9ae67fbfd0", size = 310032, upload-time = "2026-07-06T10:47:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/56/47/a0288d7329b1fe63e2734a32d19d444a96ae2b4810f545bc61e561224917/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:44c89d915a75c11d2547eaee9098fcd80398987c4bff2974a0497a925bf92c07", size = 448882, upload-time = "2026-07-06T10:47:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/01/e7/3071dfd3beb5c38204ce1cf56bf7749fce08de900fa92714b81d1d8ca1f2/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:358650d5bda9c635da699c53adf4e8134af492ecc79c960f917eebf088bb6799", size = 223728, upload-time = "2026-07-06T10:47:55.093Z" }, + { url = "https://files.pythonhosted.org/packages/12/11/b99949f0ba2b07e9f9ffe83b9c86faa685f9080725dc21a916a607313be5/xxhash-3.8.1-cp314-cp314t-win32.whl", hash = "sha256:c240939e963653054fc7e4a17c382829cda4aa88a7daf0af841715dbded1b497", size = 33150, upload-time = "2026-07-06T10:47:57.274Z" }, + { url = "https://files.pythonhosted.org/packages/54/1c/09703eb341f8416e74e58d6c6732d4b5c46de59c942363203cb237cc95b0/xxhash-3.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:7258ee276e8772599bc19e14b36f6260306e21b637190cd7cb489a2449d48684", size = 34005, upload-time = "2026-07-06T10:47:59.434Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f9/6ed7251bb6a8af10ac73b1821c60583d2826e5b2064e45a979c935287c98/xxhash-3.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:8f454166c2ffed45636c8d501741e649851ba2f346c4eb73a64c07ac00428f20", size = 30239, upload-time = "2026-07-06T10:48:01.874Z" }, +] + [[package]] name = "yarl" version = "1.23.0" @@ -4830,3 +5255,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/fb/5f5e7b40a2f4efd873fe173624795ca47eaa22e29051270c981361b45209/zope_interface-8.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05a0e42d6d830f547e114de2e7cd15750dc6c0c78f8138e6c5035e51ddfff37c", size = 264390, upload-time = "2026-01-09T08:05:42.936Z" }, { url = "https://files.pythonhosted.org/packages/f9/82/3f2bc594370bc3abd58e5f9085d263bf682a222f059ed46275cde0570810/zope_interface-8.2-cp314-cp314-win_amd64.whl", hash = "sha256:561ce42390bee90bae51cf1c012902a8033b2aaefbd0deed81e877562a116d48", size = 212585, upload-time = "2026-01-09T08:05:44.419Z" }, ] + +[[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" }, +] diff --git a/docs/wiki/domain-map.md b/docs/wiki/domain-map.md index e9cacdaed..abd3b4345 100644 --- a/docs/wiki/domain-map.md +++ b/docs/wiki/domain-map.md @@ -31,6 +31,7 @@ APIKey → Organization, Project, User # programmatic access | BatchJob | batch_job.py | Org, Project | EvaluationRun, Assessment; batch polling cron (logical) | | EvaluationDataset | evaluation.py | Org, Project, Language | EvaluationRun, STTSample (via stt_evaluation), Assessment | | EvaluationRun | evaluation.py | Dataset, Config, BatchJob, Org, Project, Language | STTResult, TTSResult; Langfuse scores (logical); console UI (logical) | +| EvaluationIterationRun | evaluation_iteration.py | Dataset, Config, Org, Project | EvaluationRun, Job (referenced only inside the LangGraph checkpoint state, not FK columns on this table, logical); callback_url caller (logical) | | STTSample / STTResult | stt_evaluation.py | Dataset, Run, File, Language | human annotation UI (logical) | | TTSResult | tts_evaluation.py | Run, Org, Project | human annotation UI (logical) | | Assessment / AssessmentRun | assessment.py | Config, Dataset, BatchJob, Org, Project | console UI (logical) | diff --git a/docs/wiki/modules/evaluations.md b/docs/wiki/modules/evaluations.md index cd0efcd3d..0b969e41e 100644 --- a/docs/wiki/modules/evaluations.md +++ b/docs/wiki/modules/evaluations.md @@ -10,6 +10,7 @@ All paths relative to `backend/app/`. - `api/routes/evaluations/evaluation_v2.py` — `POST /api/v2/evaluations`, replica of v1 run trigger + native ground-truth LLM judge (Langfuse-free); mounted under `settings.API_V2_STR` - `api/routes/evaluations/dataset_v2.py` — `POST /api/v2/evaluations/datasets`, Langfuse-free dataset upload; stores only the original CSV in S3 and records `duplication_factor` as metadata (rows expanded ×factor at run time, not physically duplicated) - `api/routes/evaluations/prompt_improvement_v2.py` — `POST /api/v2/evaluations/{evaluation_id}/improve-prompt`, prompt iteration off the three-metric judge results (requires an `is_judge_run` run); same body as v1, returns a recommendation of type `prompt` +- `api/routes/evaluations/iteration_v2.py` — `POST /api/v2/evaluations/iterations`, kicks off the automated eval → improve-prompt → eval loop (see Async); returns `202` with an `EvaluationIterationRunImmediatePublic` handle, final round-by-round report delivered to `callback_url` - `api/routes/stt_evaluations/`, `api/routes/tts_evaluations/` — STT/TTS - `api/routes/cron.py` — batch polling trigger @@ -20,14 +21,17 @@ All paths relative to `backend/app/`. | `stt_sample`, `stt_result` | `models/stt_evaluation.py` | | `tts_result` | `models/tts_evaluation.py` | | `batch_job` (BatchJob) | `models/batch_job.py` | +| `evaluation_iteration_run` (EvaluationIterationRun) | `models/evaluation_iteration.py` | Key `EvaluationRun` JSONB fields: `score` (per-trace `scores` + `summary_scores`), `per_item_scores`, `cost` (per-stage), `unscoreable`. References `config_id`, `batch_job_id`. v2 judge field on `EvaluationRun`: `is_judge_run` (bool marker gating native judging + Langfuse skip). All judge metrics are trace-only — per-row score + reasoning live in the `score_trace_url` trace unit (the native source of truth); there is no per-metric backup column. Judging is system-config only — always the fallback model (`gpt-5-mini`) + built-in prompts, no per-run config. +`EvaluationIterationRun` is a thin bookkeeping row only (`status`, `stop_reason`, `dataset_id`, `config_id`, `initial_config_version`, `callback_url`, `error_message`) — round-by-round state (`round_number`, `current_eval_run_id`, `current_improvement_job_id`, `history`, `best_*`, `consecutive_low_delta_rounds`) lives entirely in the LangGraph checkpoint keyed by `thread_id = str(id)`, not on this table. No FK to `EvaluationRun`/`Job`; those are referenced only inside the checkpoint state. + ## Services / CRUD -- `services/evaluations/` — `evaluation.py`, `dataset.py` (`upload_dataset`; `use_langfuse=False` is the v2 Langfuse-free upload), `fast.py`, `batch_job.py`, `validators.py`, `prompt_improvement.py` +- `services/evaluations/` — `evaluation.py`, `dataset.py` (`upload_dataset`; `use_langfuse=False` is the v2 Langfuse-free upload), `fast.py` (`validate_fast_evaluation_inputs` extracted for reuse by both the direct eval-start path and the iteration loop), `batch_job.py`, `validators.py`, `prompt_improvement.py`, `iteration.py` (`validate_and_start_evaluation_iteration`, `compute_round_scores`), `iteration_graph.py` (the LangGraph `StateGraph`: nodes, checkpointer) - `services/stt_evaluations/`, `services/tts_evaluations/` -- `crud/evaluations/` — `core.py`, `batch.py`, `fast.py`, `judge.py` (`METRIC_REGISTRY` + combined judge call; `ground_truth`, `prompt`, and `knowledge_base` metrics, applied per-row by which required inputs the row carries), `score.py`, `embeddings.py`, `cost.py`, `langfuse.py`, `merge.py`, `processing.py`, `cron.py` +- `crud/evaluations/` — `core.py`, `batch.py`, `fast.py`, `judge.py` (`METRIC_REGISTRY` + combined judge call; `ground_truth`, `prompt`, and `knowledge_base` metrics, applied per-row by which required inputs the row carries), `score.py`, `embeddings.py`, `cost.py`, `langfuse.py`, `merge.py`, `processing.py`, `cron.py`, `iteration.py` (thin-row CRUD for the iteration loop) - `core/batch/` — shared provider batch clients: `openai.py`, `gemini.py`, `anthropic.py`, `polling.py`, `operations.py` ## Async @@ -35,9 +39,11 @@ v2 judge field on `EvaluationRun`: `is_judge_run` (bool marker gating native jud - Fast text runs (v1 cosine + v2 judge) fan out `ceil(total_items / EVAL_FAST_CHUNK_SIZE)` `run_evaluation_fast_chunk` tasks (responses only), then a cron barrier (`dispatch_fast_evaluation_barriers`) enqueues one `run_evaluation_fast_aggregate` once every chunk has a `raw_output_url`. The aggregate merges chunks, then (v2) judges **every** row in that single task — so the judge pool is sized by its own `EVAL_JUDGE_CONCURRENCY` (not the response stage's `EVAL_FAST_API_CONCURRENCY`) to clear the max dataset (`EVAL_FAST_MAX_UNIQUE_ROWS` × `duplication_factor`) under the aggregate's `CELERY_TASK_SOFT_TIME_LIMIT`. No judge fan-out / second barrier. - Prompt improvement is job-based with callback delivery: `POST /evaluations/{id}/improve-prompt` validates preconditions, enqueues a `Job` (`JobType.PROMPT_IMPROVEMENT`, `models/job.py`) run by Celery task `run_prompt_improvement` (`celery/tasks/job_execution.py`), and returns `202` with an `LLMJobImmediatePublic` handle. On finish the worker POSTs a single best-effort callback to the caller-supplied `callback_url` (SSRF-guarded via `validate_callback_url`): an `APIResponse[PromptImprovementJobPublic]` (`models/evaluation.py`) carrying the new `ConfigVersion` on success or `error_message` on failure. The `ConfigVersion` is persisted regardless of callback outcome. Celery redelivery of a `SUCCESS` job re-sends the callback without re-running the LLM. - v2 prompt iteration reuses the same job/Celery/config-version machinery (`services/evaluations/prompt_improvement.py`) and branches on `run.is_judge_run`: the v2 route calls `start_prompt_improvement_job(..., require_judge_run=True)` (a non-judge run → `422 not_a_judge_run`), and the worker drafts from the three-metric judge trace (`_draft_improved_prompt(is_judge_run=True)`, using each metric's score + reasoning) and delivers a `PromptRecommendationJobPublic` callback carrying `recommendation_type` (`Literal["prompt"]`, `models/evaluation.py`; widens to a union when knowledge-base / model recommendations land). Non-judge (v1) runs keep the default `_draft_improved_prompt` brief + `PromptImprovementJobPublic` path unchanged. +- Evaluation iteration loop (`POST /evaluations/iterations`) chains fast-eval + v2 prompt improvement into a self-driving cycle via LangGraph (`services/evaluations/iteration_graph.py`): `start_eval_node` → `wait_eval_node` → (conditional) `start_improve_node` → `wait_improve_node` → loops back to `start_eval_node`, or → `finalize_node`. Stop-score = mean(`Adherence to Ground Truth`, `Adherence to Prompt`) from `EvaluationRun.score["summary_scores"]` (`compute_round_scores`); `Adherence to Knowledge Base` is recorded per round for visibility only, never gates stopping. Stops on 3 consecutive rounds with `