Make the first RAG slice executable without risking production data
Some checks failed
verify / verify (push) Has been cancelled

The Stage 1 foundation now proves provider contracts with mocks and validates PostgreSQL/pgvector ingestion, approval binding, retrieval, reranking, and idempotency using only synthetic data. Live Bailian validation remains gated on rotating the exposed key.

Constraint: The key shown in chat is compromised and cannot be used or committed

Rejected: Mark Stage 1 complete from mock and offline results | real three-model smoke is still required

Confidence: high

Scope-risk: moderate

Reversibility: clean

Directive: Do not enable real-data ingestion until Stage 3 cloud approval and outbound manifest controls are enforced end to end

Tested: make verify; 41 pytest tests; strict mypy; Ruff; Compose config; pinned image build; empty-volume migration; role denial; two idempotent 20-vector seeds; database restart persistence

Not-tested: Live Bailian calls require a newly rotated key; React product UI is not implemented
This commit is contained in:
2026-07-12 15:41:58 +08:00
parent ec1acb36b5
commit f4ba5d5342
61 changed files with 6886 additions and 20 deletions

10
backend/.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.venv
.uv-cache
__pycache__
.pytest_cache
.mypy_cache
.ruff_cache
tests
*.pyc
*.pyo
.DS_Store

33
backend/Dockerfile Normal file
View File

@@ -0,0 +1,33 @@
# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
FROM ghcr.io/astral-sh/uv:0.11.3@sha256:90bbb3c16635e9627f49eec6539f956d70746c409209041800a0280b93152823 AS uv
FROM python:3.12-slim-bookworm@sha256:8a7e7cc04fd3e2bd787f7f24e22d5d119aa590d429b50c95dfe12b3abe52f48b AS runtime
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
PATH="/app/.venv/bin:$PATH"
RUN groupadd --system --gid 10001 app \
&& useradd --system --uid 10001 --gid app --home-dir /app app
COPY --from=uv /uv /uvx /bin/
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project
COPY alembic.ini ./
COPY migrations ./migrations
COPY app ./app
COPY README.md ./
RUN uv sync --frozen --no-dev \
&& chown -R app:app /app
USER app
EXPOSE 8000
CMD ["python", "-m", "app.main"]

46
backend/alembic.ini Normal file
View File

@@ -0,0 +1,46 @@
[alembic]
script_location = %(here)s/migrations
prepend_sys_path = %(here)s
path_separator = os
version_path_separator = os
output_encoding = utf-8
# The runtime URL is assembled in migrations/env.py from POSTGRES_* and
# POSTGRES_PASSWORD_FILE. Never place a credential in this file.
sqlalchemy.url = postgresql+psycopg://invalid:invalid@invalid/invalid
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

3
backend/app/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""Geological RAG backend package."""
__version__ = "0.1.0"

View File

@@ -1 +0,0 @@

View File

@@ -0,0 +1,11 @@
"""Alibaba Cloud Model Studio provider adapters."""
from app.adapters.bailian.chat import BailianChatAdapter
from app.adapters.bailian.embedding import BailianEmbeddingAdapter
from app.adapters.bailian.rerank import BailianRerankerAdapter
__all__ = [
"BailianChatAdapter",
"BailianEmbeddingAdapter",
"BailianRerankerAdapter",
]

View File

@@ -0,0 +1,382 @@
"""Shared HTTP and validation machinery for Alibaba Cloud Model Studio."""
from __future__ import annotations
import asyncio
import re
import secrets
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import UTC, datetime
from email.utils import parsedate_to_datetime
from time import perf_counter
from typing import Any, Self
from urllib.parse import urlsplit
import httpx
from app.ports.model_providers import (
ModelProviderError,
ProviderErrorKind,
ProviderUsage,
TokenCounter,
)
_SAFE_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:/-]{0,127}$")
_BAILIAN_BEIJING_HOST = re.compile(
r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.cn-beijing\.maas\.aliyuncs\.com$"
)
def conservative_utf8_token_count(text: str) -> int:
"""Return a dependency-free conservative upper bound for byte-BPE tokens.
Production may inject the provider tokenizer through ``token_counter``. A
UTF-8 byte count is deliberately conservative and, unlike a word count,
does not undercount Chinese text or byte-fallback tokens.
"""
return len(text.encode("utf-8"))
def count_tokens(
counter: TokenCounter,
text: str,
*,
operation: str,
) -> int:
try:
count = counter(text)
except Exception:
raise sanitized_error(
operation=operation,
kind=ProviderErrorKind.INVALID_REQUEST,
provider_code="token_count_failed",
) from None
if isinstance(count, bool) or not isinstance(count, int) or count < 0:
raise sanitized_error(
operation=operation,
kind=ProviderErrorKind.INVALID_REQUEST,
provider_code="invalid_token_count",
)
return count
def sanitized_error(
*,
operation: str,
kind: ProviderErrorKind,
status_code: int | None = None,
provider_code: str | None = None,
request_id: str | None = None,
retryable: bool = False,
) -> ModelProviderError:
return ModelProviderError(
operation=operation,
kind=kind,
status_code=status_code,
provider_code=provider_code,
request_id=request_id,
retryable=retryable,
)
def invalid_request(operation: str, code: str) -> ModelProviderError:
return sanitized_error(
operation=operation,
kind=ProviderErrorKind.INVALID_REQUEST,
provider_code=code,
)
def invalid_response(operation: str, code: str) -> ModelProviderError:
return sanitized_error(
operation=operation,
kind=ProviderErrorKind.INVALID_RESPONSE,
provider_code=code,
)
def parse_usage(value: Any) -> ProviderUsage:
if not isinstance(value, Mapping):
return ProviderUsage()
return ProviderUsage(
input_tokens=_first_nonnegative_int(
value.get("prompt_tokens"),
value.get("input_tokens"),
),
output_tokens=_first_nonnegative_int(
value.get("completion_tokens"),
value.get("output_tokens"),
),
total_tokens=_nonnegative_int(value.get("total_tokens")),
)
def response_model(
body: Mapping[str, Any],
requested_model: str,
*,
sensitive_values: Sequence[str] = (),
) -> str:
return safe_identifier(body.get("model"), sensitive_values=sensitive_values) or requested_model
def safe_identifier(
value: Any,
*,
sensitive_values: Sequence[str] = (),
) -> str | None:
if not isinstance(value, str) or not _SAFE_IDENTIFIER.fullmatch(value):
return None
if any(value in sensitive or sensitive in value for sensitive in sensitive_values if sensitive):
return None
return value
def extract_request_id(
body: Mapping[str, Any],
*,
sensitive_values: Sequence[str] = (),
) -> str | None:
return safe_identifier(
body.get("id") or body.get("request_id"),
sensitive_values=sensitive_values,
)
def _nonnegative_int(value: Any) -> int | None:
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
return None
return int(value)
def _first_nonnegative_int(*values: Any) -> int | None:
for value in values:
parsed = _nonnegative_int(value)
if parsed is not None:
return parsed
return None
@dataclass(frozen=True, slots=True)
class JsonResponse:
body: Mapping[str, Any]
elapsed_ms: float
class BailianHttpAdapter:
"""Minimal async HTTP client with sanitized error translation."""
def __init__(
self,
*,
api_key: str,
base_url: str,
expected_path: str,
http_client: httpx.AsyncClient | None,
timeout_seconds: float,
max_retries: int = 0,
retry_base_seconds: float = 0.5,
) -> None:
if not api_key or api_key != api_key.strip():
raise invalid_request("configuration", "invalid_api_key_value")
if timeout_seconds <= 0:
raise invalid_request("configuration", "invalid_timeout")
if isinstance(max_retries, bool) or not isinstance(max_retries, int) or max_retries < 0:
raise invalid_request("configuration", "invalid_max_retries")
if retry_base_seconds < 0:
raise invalid_request("configuration", "invalid_retry_base")
self._base_url = _validated_base_url(base_url, expected_path)
self._api_key = api_key
self._owns_client = http_client is None
self._client = http_client or httpx.AsyncClient(
timeout=httpx.Timeout(timeout_seconds),
follow_redirects=False,
)
self._max_retries = max_retries
self._retry_base_seconds = retry_base_seconds
async def aclose(self) -> None:
if self._owns_client:
await self._client.aclose()
async def __aenter__(self) -> Self:
return self
async def __aexit__(self, *_: object) -> None:
await self.aclose()
def _url(self, path: str) -> str:
return f"{self._base_url}/{path.lstrip('/')}"
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
}
async def _post_json(
self,
*,
operation: str,
path: str,
payload: Mapping[str, Any],
sensitive_values: Sequence[str],
) -> JsonResponse:
started = perf_counter()
for attempt in range(self._max_retries + 1):
try:
response = await self._client.post(
self._url(path),
headers=self._headers(),
json=payload,
)
except httpx.TimeoutException:
error = sanitized_error(
operation=operation,
kind=ProviderErrorKind.TIMEOUT,
provider_code="request_timeout",
retryable=True,
)
if await self._maybe_retry(error, attempt=attempt, response=None):
continue
raise error from None
except httpx.HTTPError:
error = sanitized_error(
operation=operation,
kind=ProviderErrorKind.TRANSPORT,
provider_code="transport_error",
retryable=True,
)
if await self._maybe_retry(error, attempt=attempt, response=None):
continue
raise error from None
if response.status_code >= 400:
try:
self._raise_http_error(
operation=operation,
response=response,
sensitive_values=(*sensitive_values, self._api_key),
)
except ModelProviderError as error:
if await self._maybe_retry(error, attempt=attempt, response=response):
continue
raise
try:
body = response.json()
except ValueError:
raise invalid_response(operation, "invalid_json") from None
if not isinstance(body, Mapping):
raise invalid_response(operation, "invalid_json_object")
return JsonResponse(body=body, elapsed_ms=(perf_counter() - started) * 1000)
raise AssertionError("bounded provider retry loop exhausted unexpectedly")
async def _maybe_retry(
self,
error: ModelProviderError,
*,
attempt: int,
response: httpx.Response | None,
) -> bool:
if not error.retryable or attempt >= self._max_retries:
return False
await asyncio.sleep(self._retry_delay(attempt=attempt, response=response))
return True
def _retry_delay(self, *, attempt: int, response: httpx.Response | None) -> float:
if response is not None:
retry_after = response.headers.get("Retry-After")
if retry_after:
parsed_delay = _parse_retry_after(retry_after)
if parsed_delay is not None:
return min(max(parsed_delay, 0.0), 30.0)
base_delay = min(self._retry_base_seconds * (2**attempt), 30.0)
if base_delay == 0:
return 0.0
jitter = base_delay * (float(secrets.randbelow(251)) / 1000.0)
return float(min(base_delay + jitter, 30.0))
def _raise_http_error(
self,
*,
operation: str,
response: httpx.Response,
sensitive_values: Sequence[str],
) -> None:
body: Mapping[str, Any] = {}
try:
decoded = response.json()
if isinstance(decoded, Mapping):
body = decoded
except (ValueError, httpx.ResponseNotRead):
pass
nested_error = body.get("error")
error_body = nested_error if isinstance(nested_error, Mapping) else body
code = safe_identifier(
error_body.get("code"),
sensitive_values=sensitive_values,
)
request_id = extract_request_id(body, sensitive_values=sensitive_values)
kind, retryable = _kind_for_status(response.status_code)
raise sanitized_error(
operation=operation,
kind=kind,
status_code=response.status_code,
provider_code=code,
request_id=request_id,
retryable=retryable,
) from None
def _validated_base_url(base_url: str, expected_path: str) -> str:
if not base_url or base_url != base_url.strip():
raise invalid_request("configuration", "invalid_base_url")
parsed = urlsplit(base_url)
normalized_path = parsed.path.rstrip("/")
if (
parsed.scheme != "https"
or not parsed.hostname
or not _BAILIAN_BEIJING_HOST.fullmatch(parsed.hostname)
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
or normalized_path != expected_path
):
raise invalid_request("configuration", "invalid_base_url")
return base_url.rstrip("/")
def _kind_for_status(status_code: int) -> tuple[ProviderErrorKind, bool]:
if status_code == 400:
return ProviderErrorKind.INVALID_REQUEST, False
if status_code == 401:
return ProviderErrorKind.AUTHENTICATION, False
if status_code == 403:
return ProviderErrorKind.PERMISSION_DENIED, False
if status_code == 404:
return ProviderErrorKind.NOT_FOUND, False
if status_code == 408:
return ProviderErrorKind.TIMEOUT, True
if status_code == 429:
return ProviderErrorKind.RATE_LIMITED, True
return ProviderErrorKind.UPSTREAM, status_code >= 500
def _parse_retry_after(value: str) -> float | None:
try:
return float(value)
except ValueError:
try:
retry_at = parsedate_to_datetime(value)
except (TypeError, ValueError, OverflowError):
return None
if retry_at.tzinfo is None:
retry_at = retry_at.replace(tzinfo=UTC)
return (retry_at - datetime.now(UTC)).total_seconds()

View File

@@ -0,0 +1,326 @@
"""Alibaba Cloud Model Studio OpenAI-compatible chat adapter."""
from __future__ import annotations
import json
from collections.abc import AsyncIterator, Mapping, Sequence
from time import perf_counter
from typing import Any
import httpx
from app.adapters.bailian._base import (
BailianHttpAdapter,
extract_request_id,
invalid_request,
invalid_response,
parse_usage,
response_model,
sanitized_error,
)
from app.ports.model_providers import (
ChatCompletionResult,
ChatMessage,
ChatStreamEvent,
ModelProviderError,
ProviderErrorKind,
ProviderUsage,
)
_ALLOWED_ROLES = frozenset({"system", "user", "assistant"})
class BailianChatAdapter(BailianHttpAdapter):
"""Call chat completions with thinking and web search forcibly disabled."""
def __init__(
self,
*,
api_key: str,
base_url: str,
model: str = "deepseek-v4-flash",
http_client: httpx.AsyncClient | None = None,
timeout_seconds: float = 60.0,
max_retries: int = 0,
retry_base_seconds: float = 0.5,
) -> None:
if not model or model != model.strip():
raise invalid_request("chat.configuration", "invalid_model")
super().__init__(
api_key=api_key,
base_url=base_url,
expected_path="/compatible-mode/v1",
http_client=http_client,
timeout_seconds=timeout_seconds,
max_retries=max_retries,
retry_base_seconds=retry_base_seconds,
)
self._model = model
async def complete(
self,
messages: Sequence[ChatMessage],
*,
max_tokens: int,
) -> ChatCompletionResult:
operation = "chat.complete"
validated_messages = self._validate_messages(
messages,
max_tokens=max_tokens,
operation=operation,
)
payload = self._payload(validated_messages, max_tokens=max_tokens, stream=False)
sensitive_values = tuple(message.content for message in validated_messages)
response = await self._post_json(
operation=operation,
path="chat/completions",
payload=payload,
sensitive_values=sensitive_values,
)
content, finish_reason = self._parse_completion(
response.body,
operation=operation,
)
return ChatCompletionResult(
content=content,
finish_reason=finish_reason,
model=response_model(
response.body,
self._model,
sensitive_values=(*sensitive_values, self._api_key),
),
request_id=extract_request_id(
response.body,
sensitive_values=(*sensitive_values, self._api_key),
),
usage=parse_usage(response.body.get("usage")),
elapsed_ms=response.elapsed_ms,
)
async def stream(
self,
messages: Sequence[ChatMessage],
*,
max_tokens: int,
) -> AsyncIterator[ChatStreamEvent]:
operation = "chat.stream"
validated_messages = self._validate_messages(
messages,
max_tokens=max_tokens,
operation=operation,
)
payload = self._payload(validated_messages, max_tokens=max_tokens, stream=True)
sensitive_values = tuple(message.content for message in validated_messages)
started = perf_counter()
for attempt in range(self._max_retries + 1):
emitted = False
try:
async with self._client.stream(
"POST",
self._url("chat/completions"),
headers=self._headers(),
json=payload,
) as response:
if response.status_code >= 400:
await response.aread()
try:
self._raise_http_error(
operation=operation,
response=response,
sensitive_values=(*sensitive_values, self._api_key),
)
except ModelProviderError as error:
if await self._maybe_retry(
error,
attempt=attempt,
response=response,
):
continue
raise
async for line in response.aiter_lines():
if not line or line.startswith(":"):
continue
if not line.startswith("data:"):
raise invalid_response(operation, "invalid_sse_event")
raw_data = line[5:].strip()
if raw_data == "[DONE]":
return
event_body = self._decode_stream_event(raw_data, operation=operation)
event = self._parse_stream_event(
event_body,
operation=operation,
sensitive_values=sensitive_values,
elapsed_ms=(perf_counter() - started) * 1000,
)
emitted = True
yield event
return
except ModelProviderError as error:
if not emitted and await self._maybe_retry(
error,
attempt=attempt,
response=None,
):
continue
raise
except httpx.TimeoutException:
timeout_error = sanitized_error(
operation=operation,
kind=ProviderErrorKind.TIMEOUT,
provider_code="request_timeout",
retryable=True,
)
if not emitted and await self._maybe_retry(
timeout_error,
attempt=attempt,
response=None,
):
continue
raise timeout_error from None
except httpx.HTTPError:
transport_error = sanitized_error(
operation=operation,
kind=ProviderErrorKind.TRANSPORT,
provider_code="transport_error",
retryable=True,
)
if not emitted and await self._maybe_retry(
transport_error,
attempt=attempt,
response=None,
):
continue
raise transport_error from None
raise AssertionError("bounded chat retry loop exhausted unexpectedly")
def _validate_messages(
self,
messages: object,
*,
max_tokens: int,
operation: str,
) -> tuple[ChatMessage, ...]:
if isinstance(messages, (str, bytes)) or not isinstance(messages, Sequence):
raise invalid_request(operation, "invalid_message_collection")
validated = tuple(messages)
if not validated:
raise invalid_request(operation, "empty_messages")
if isinstance(max_tokens, bool) or not isinstance(max_tokens, int) or max_tokens <= 0:
raise invalid_request(operation, "invalid_max_tokens")
for message in validated:
if (
not isinstance(message, ChatMessage)
or message.role not in _ALLOWED_ROLES
or not isinstance(message.content, str)
or not message.content
):
raise invalid_request(operation, "invalid_message")
return validated
def _payload(
self,
messages: tuple[ChatMessage, ...],
*,
max_tokens: int,
stream: bool,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"model": self._model,
"messages": [
{"role": message.role, "content": message.content} for message in messages
],
"max_tokens": max_tokens,
"stream": stream,
"enable_thinking": False,
"enable_search": False,
}
if stream:
payload["stream_options"] = {"include_usage": True}
return payload
def _parse_completion(
self,
body: Mapping[str, Any],
*,
operation: str,
) -> tuple[str, str | None]:
choices = body.get("choices")
if not isinstance(choices, list) or len(choices) != 1:
raise invalid_response(operation, "invalid_choices")
choice = choices[0]
if not isinstance(choice, Mapping):
raise invalid_response(operation, "invalid_choice")
message = choice.get("message")
if not isinstance(message, Mapping):
raise invalid_response(operation, "invalid_message")
content = message.get("content")
if not isinstance(content, str):
raise invalid_response(operation, "invalid_content")
finish_reason = choice.get("finish_reason")
if finish_reason is not None and not isinstance(finish_reason, str):
raise invalid_response(operation, "invalid_finish_reason")
return content, finish_reason
def _decode_stream_event(
self,
raw_data: str,
*,
operation: str,
) -> Mapping[str, Any]:
try:
decoded = json.loads(raw_data)
except (TypeError, ValueError):
raise invalid_response(operation, "invalid_stream_json") from None
if not isinstance(decoded, Mapping):
raise invalid_response(operation, "invalid_stream_object")
return decoded
def _parse_stream_event(
self,
body: Mapping[str, Any],
*,
operation: str,
sensitive_values: tuple[str, ...],
elapsed_ms: float,
) -> ChatStreamEvent:
choices = body.get("choices")
delta = ""
finish_reason: str | None = None
if choices not in (None, []):
if not isinstance(choices, list) or len(choices) != 1:
raise invalid_response(operation, "invalid_stream_choices")
choice = choices[0]
if not isinstance(choice, Mapping):
raise invalid_response(operation, "invalid_stream_choice")
raw_delta = choice.get("delta")
if not isinstance(raw_delta, Mapping):
raise invalid_response(operation, "invalid_stream_delta")
content = raw_delta.get("content", "")
if not isinstance(content, str):
raise invalid_response(operation, "invalid_stream_content")
delta = content
raw_finish_reason = choice.get("finish_reason")
if raw_finish_reason is not None and not isinstance(raw_finish_reason, str):
raise invalid_response(operation, "invalid_stream_finish_reason")
finish_reason = raw_finish_reason
usage = parse_usage(body.get("usage"))
if choices in (None, []) and usage == ProviderUsage():
raise invalid_response(operation, "empty_stream_event")
return ChatStreamEvent(
delta=delta,
finish_reason=finish_reason,
model=response_model(
body,
self._model,
sensitive_values=(*sensitive_values, self._api_key),
),
request_id=extract_request_id(
body,
sensitive_values=(*sensitive_values, self._api_key),
),
usage=usage,
elapsed_ms=elapsed_ms,
)

View File

@@ -0,0 +1,177 @@
"""Alibaba Cloud Model Studio OpenAI-compatible embedding adapter."""
from __future__ import annotations
import math
from collections.abc import Mapping, Sequence
from typing import Any
import httpx
from app.adapters.bailian._base import (
BailianHttpAdapter,
conservative_utf8_token_count,
count_tokens,
extract_request_id,
invalid_request,
invalid_response,
parse_usage,
response_model,
)
from app.ports.model_providers import EmbeddingResult, TokenCounter
EMBEDDING_MAX_BATCH_SIZE = 10
EMBEDDING_MAX_TOKENS_PER_TEXT = 8_192
EMBEDDING_MAX_TOKENS_PER_REQUEST = 33_000
EMBEDDING_DIMENSIONS = 1_024
class BailianEmbeddingAdapter(BailianHttpAdapter):
"""Call ``compatible-mode/v1/embeddings`` with strict input/output checks."""
def __init__(
self,
*,
api_key: str,
base_url: str,
model: str = "text-embedding-v4",
dimensions: int = EMBEDDING_DIMENSIONS,
token_counter: TokenCounter = conservative_utf8_token_count,
http_client: httpx.AsyncClient | None = None,
timeout_seconds: float = 30.0,
max_retries: int = 0,
retry_base_seconds: float = 0.5,
) -> None:
if not model or model != model.strip():
raise invalid_request("embedding.configuration", "invalid_model")
if isinstance(dimensions, bool) or dimensions != EMBEDDING_DIMENSIONS:
raise invalid_request("embedding.configuration", "unsupported_dimensions")
if not callable(token_counter):
raise invalid_request("embedding.configuration", "invalid_token_counter")
super().__init__(
api_key=api_key,
base_url=base_url,
expected_path="/compatible-mode/v1",
http_client=http_client,
timeout_seconds=timeout_seconds,
max_retries=max_retries,
retry_base_seconds=retry_base_seconds,
)
self._model = model
self._dimensions = dimensions
self._token_counter = token_counter
async def embed_documents(self, texts: Sequence[str]) -> EmbeddingResult:
operation = "embedding.create"
validated_texts = self._validate_texts(texts, operation=operation)
payload = {
"model": self._model,
"input": list(validated_texts),
"dimensions": self._dimensions,
"encoding_format": "float",
}
response = await self._post_json(
operation=operation,
path="embeddings",
payload=payload,
sensitive_values=validated_texts,
)
vectors = self._parse_vectors(
response.body,
expected_count=len(validated_texts),
operation=operation,
)
return EmbeddingResult(
vectors=vectors,
model=response_model(
response.body,
self._model,
sensitive_values=(*validated_texts, self._api_key),
),
request_id=extract_request_id(
response.body,
sensitive_values=(*validated_texts, self._api_key),
),
usage=parse_usage(response.body.get("usage")),
elapsed_ms=response.elapsed_ms,
)
async def embed_query(self, text: str) -> EmbeddingResult:
return await self.embed_documents((text,))
def _validate_texts(
self,
texts: Sequence[str],
*,
operation: str,
) -> tuple[str, ...]:
if isinstance(texts, (str, bytes)) or not isinstance(texts, Sequence):
raise invalid_request(operation, "invalid_input_collection")
validated = tuple(texts)
if not validated:
raise invalid_request(operation, "empty_input")
if len(validated) > EMBEDDING_MAX_BATCH_SIZE:
raise invalid_request(operation, "batch_size_exceeded")
total_tokens = 0
for text in validated:
if not isinstance(text, str) or not text:
raise invalid_request(operation, "invalid_input_text")
token_count = count_tokens(
self._token_counter,
text,
operation=operation,
)
if token_count > EMBEDDING_MAX_TOKENS_PER_TEXT:
raise invalid_request(operation, "text_token_limit_exceeded")
total_tokens += token_count
if total_tokens > EMBEDDING_MAX_TOKENS_PER_REQUEST:
raise invalid_request(operation, "request_token_limit_exceeded")
return validated
def _parse_vectors(
self,
body: Mapping[str, Any],
*,
expected_count: int,
operation: str,
) -> tuple[tuple[float, ...], ...]:
data = body.get("data")
if not isinstance(data, list) or len(data) != expected_count:
raise invalid_response(operation, "invalid_embedding_count")
by_index: list[tuple[float, ...] | None] = [None] * expected_count
for item in data:
if not isinstance(item, Mapping):
raise invalid_response(operation, "invalid_embedding_item")
index = item.get("index")
if (
isinstance(index, bool)
or not isinstance(index, int)
or not 0 <= index < expected_count
or by_index[index] is not None
):
raise invalid_response(operation, "invalid_embedding_index")
raw_vector = item.get("embedding")
if not isinstance(raw_vector, list) or len(raw_vector) != self._dimensions:
raise invalid_response(operation, "invalid_embedding_dimensions")
vector: list[float] = []
for component in raw_vector:
if isinstance(component, bool) or not isinstance(component, (int, float)):
raise invalid_response(operation, "invalid_embedding_component")
normalized = float(component)
if not math.isfinite(normalized):
raise invalid_response(operation, "invalid_embedding_component")
vector.append(normalized)
norm = math.hypot(*vector)
if not math.isfinite(norm) or norm <= 0:
raise invalid_response(operation, "invalid_embedding_norm")
by_index[index] = tuple(vector)
if any(vector is None for vector in by_index):
raise invalid_response(operation, "missing_embedding_index")
return tuple(vector for vector in by_index if vector is not None)

View File

@@ -0,0 +1,208 @@
"""Alibaba Cloud Model Studio compatible rerank adapter."""
from __future__ import annotations
import math
from collections.abc import Mapping, Sequence
from typing import Any
import httpx
from app.adapters.bailian._base import (
BailianHttpAdapter,
conservative_utf8_token_count,
count_tokens,
extract_request_id,
invalid_request,
invalid_response,
parse_usage,
response_model,
)
from app.ports.model_providers import RankedItem, RerankResult, TokenCounter
RERANK_MAX_DOCUMENTS = 500
RERANK_MAX_TOKENS_PER_TEXT = 4_000
RERANK_MAX_TOKENS_PER_REQUEST = 120_000
class BailianRerankerAdapter(BailianHttpAdapter):
"""Call ``compatible-api/v1/reranks`` and map indices locally."""
def __init__(
self,
*,
api_key: str,
base_url: str,
model: str = "qwen3-rerank",
token_counter: TokenCounter = conservative_utf8_token_count,
http_client: httpx.AsyncClient | None = None,
timeout_seconds: float = 30.0,
max_retries: int = 0,
retry_base_seconds: float = 0.5,
) -> None:
if not model or model != model.strip():
raise invalid_request("rerank.configuration", "invalid_model")
if not callable(token_counter):
raise invalid_request("rerank.configuration", "invalid_token_counter")
super().__init__(
api_key=api_key,
base_url=base_url,
expected_path="/compatible-api/v1",
http_client=http_client,
timeout_seconds=timeout_seconds,
max_retries=max_retries,
retry_base_seconds=retry_base_seconds,
)
self._model = model
self._token_counter = token_counter
async def rerank(
self,
query: str,
documents: Sequence[str],
*,
top_n: int,
instruct: str | None = None,
) -> RerankResult:
operation = "rerank.create"
validated_documents = self._validate_request(
query=query,
documents=documents,
top_n=top_n,
instruct=instruct,
operation=operation,
)
payload: dict[str, Any] = {
"model": self._model,
"query": query,
"documents": list(validated_documents),
"top_n": top_n,
}
if instruct is not None:
payload["instruct"] = instruct
sensitive_values = (
query,
*validated_documents,
*((instruct,) if instruct is not None else ()),
)
response = await self._post_json(
operation=operation,
path="reranks",
payload=payload,
sensitive_values=sensitive_values,
)
items = self._parse_results(
response.body,
documents=validated_documents,
top_n=top_n,
operation=operation,
)
return RerankResult(
items=items,
model=response_model(
response.body,
self._model,
sensitive_values=(*sensitive_values, self._api_key),
),
request_id=extract_request_id(
response.body,
sensitive_values=(*sensitive_values, self._api_key),
),
usage=parse_usage(response.body.get("usage")),
elapsed_ms=response.elapsed_ms,
)
def _validate_request(
self,
*,
query: str,
documents: Sequence[str],
top_n: int,
instruct: str | None,
operation: str,
) -> tuple[str, ...]:
if not isinstance(query, str) or not query:
raise invalid_request(operation, "invalid_query")
if isinstance(documents, (str, bytes)) or not isinstance(documents, Sequence):
raise invalid_request(operation, "invalid_document_collection")
validated_documents = tuple(documents)
if not validated_documents:
raise invalid_request(operation, "empty_documents")
if len(validated_documents) > RERANK_MAX_DOCUMENTS:
raise invalid_request(operation, "document_count_exceeded")
if isinstance(top_n, bool) or not isinstance(top_n, int) or top_n <= 0:
raise invalid_request(operation, "invalid_top_n")
if instruct is not None and (not isinstance(instruct, str) or not instruct):
raise invalid_request(operation, "invalid_instruct")
query_tokens = count_tokens(
self._token_counter,
query,
operation=operation,
)
if query_tokens > RERANK_MAX_TOKENS_PER_TEXT:
raise invalid_request(operation, "query_token_limit_exceeded")
document_tokens_total = 0
for document in validated_documents:
if not isinstance(document, str) or not document:
raise invalid_request(operation, "invalid_document")
document_tokens = count_tokens(
self._token_counter,
document,
operation=operation,
)
if document_tokens > RERANK_MAX_TOKENS_PER_TEXT:
raise invalid_request(operation, "document_token_limit_exceeded")
document_tokens_total += document_tokens
request_tokens = query_tokens * len(validated_documents) + document_tokens_total
if request_tokens > RERANK_MAX_TOKENS_PER_REQUEST:
raise invalid_request(operation, "request_token_limit_exceeded")
return validated_documents
def _parse_results(
self,
body: Mapping[str, Any],
*,
documents: tuple[str, ...],
top_n: int,
operation: str,
) -> tuple[RankedItem, ...]:
results = body.get("results")
if not isinstance(results, list) or len(results) > min(top_n, len(documents)):
raise invalid_response(operation, "invalid_rerank_count")
seen_indices: set[int] = set()
parsed: list[RankedItem] = []
previous_score = math.inf
for item in results:
if not isinstance(item, Mapping):
raise invalid_response(operation, "invalid_rerank_item")
index = item.get("index")
if (
isinstance(index, bool)
or not isinstance(index, int)
or not 0 <= index < len(documents)
or index in seen_indices
):
raise invalid_response(operation, "invalid_rerank_index")
raw_score = item.get("relevance_score")
if isinstance(raw_score, bool) or not isinstance(raw_score, (int, float)):
raise invalid_response(operation, "invalid_rerank_score")
score = float(raw_score)
if not math.isfinite(score) or not 0 <= score <= 1 or score > previous_score:
raise invalid_response(operation, "invalid_rerank_score")
seen_indices.add(index)
previous_score = score
parsed.append(
RankedItem(
index=index,
relevance_score=score,
document=documents[index],
)
)
return tuple(parsed)

View File

@@ -0,0 +1,154 @@
"""Deterministic local providers for tests and offline Docker verification only."""
from __future__ import annotations
import hashlib
import math
import re
import time
from collections.abc import Sequence
from app.ports.model_providers import (
EmbeddingResult,
ModelProviderError,
ProviderErrorKind,
ProviderUsage,
RankedItem,
RerankResult,
)
_TOKEN_PATTERN = re.compile(r"[\u3400-\u9fff]+|[a-zA-Z0-9_.%+-]+")
def invalid_input(operation: str, code: str) -> ModelProviderError:
return ModelProviderError(
operation=operation,
kind=ProviderErrorKind.INVALID_REQUEST,
provider_code=code,
)
def lexical_features(text: str) -> tuple[str, ...]:
"""Create stable character n-grams/words without pretending to be a tokenizer."""
features: list[str] = []
for token in _TOKEN_PATTERN.findall(text.lower()):
if "\u3400" <= token[0] <= "\u9fff":
features.extend(token)
features.extend(token[index : index + 2] for index in range(len(token) - 1))
else:
features.append(token)
return tuple(features)
class FakeEmbeddingProvider:
"""Feature-hashing embedding used to validate plumbing without cloud calls."""
def __init__(self, dimension: int = 1024) -> None:
if dimension < 1:
raise ValueError("dimension must be positive")
self.dimension = dimension
def _vector(self, text: str) -> tuple[float, ...]:
vector = [0.0] * self.dimension
for feature in lexical_features(text):
digest = hashlib.sha256(feature.encode("utf-8")).digest()
index = int.from_bytes(digest[:4], "big") % self.dimension
sign = 1.0 if digest[4] & 1 else -1.0
vector[index] += sign
norm = math.sqrt(sum(value * value for value in vector))
if norm == 0:
vector[0] = 1.0
norm = 1.0
return tuple(value / norm for value in vector)
async def _embed(self, texts: Sequence[str]) -> EmbeddingResult:
if isinstance(texts, (str, bytes)) or not isinstance(texts, Sequence):
raise invalid_input("fake.embedding", "invalid_input_collection")
if not texts:
raise invalid_input("fake.embedding", "empty_input")
if len(texts) > 10:
raise invalid_input("fake.embedding", "batch_size_exceeded")
token_counts = []
for text in texts:
if not isinstance(text, str) or not text:
raise invalid_input("fake.embedding", "invalid_input_text")
token_count = len(text.encode("utf-8"))
if token_count > 8_192:
raise invalid_input("fake.embedding", "text_token_limit_exceeded")
token_counts.append(token_count)
if sum(token_counts) > 33_000:
raise invalid_input("fake.embedding", "request_token_limit_exceeded")
started = time.perf_counter()
vectors = tuple(self._vector(text) for text in texts)
return EmbeddingResult(
vectors=vectors,
model="fake-feature-hash-v1",
request_id=None,
usage=ProviderUsage(input_tokens=sum(len(lexical_features(text)) for text in texts)),
elapsed_ms=(time.perf_counter() - started) * 1000,
)
async def embed_documents(self, texts: Sequence[str]) -> EmbeddingResult:
return await self._embed(texts)
async def embed_query(self, text: str) -> EmbeddingResult:
return await self._embed([text])
class FakeReranker:
"""Lexical-overlap reranker for deterministic offline flow tests."""
async def rerank(
self,
query: str,
documents: Sequence[str],
*,
top_n: int,
instruct: str | None = None,
) -> RerankResult:
del instruct
if not isinstance(query, str) or not query:
raise invalid_input("fake.rerank", "invalid_query")
if isinstance(documents, (str, bytes)) or not isinstance(documents, Sequence):
raise invalid_input("fake.rerank", "invalid_document_collection")
if not documents:
raise invalid_input("fake.rerank", "empty_documents")
if len(documents) > 500:
raise invalid_input("fake.rerank", "document_count_exceeded")
if isinstance(top_n, bool) or not isinstance(top_n, int) or top_n <= 0:
raise invalid_input("fake.rerank", "invalid_top_n")
query_tokens = len(query.encode("utf-8"))
if query_tokens > 4_000:
raise invalid_input("fake.rerank", "query_token_limit_exceeded")
document_tokens = []
for document in documents:
if not isinstance(document, str) or not document:
raise invalid_input("fake.rerank", "invalid_document")
count = len(document.encode("utf-8"))
if count > 4_000:
raise invalid_input("fake.rerank", "document_token_limit_exceeded")
document_tokens.append(count)
if query_tokens * len(documents) + sum(document_tokens) > 120_000:
raise invalid_input("fake.rerank", "request_token_limit_exceeded")
started = time.perf_counter()
query_features = set(lexical_features(query))
ranked: list[RankedItem] = []
for index, document in enumerate(documents):
document_features = set(lexical_features(document))
union = query_features | document_features
score = len(query_features & document_features) / len(union) if union else 0.0
ranked.append(RankedItem(index=index, relevance_score=score, document=document))
ranked.sort(key=lambda item: (-item.relevance_score, item.index))
return RerankResult(
items=tuple(ranked[:top_n]),
model="fake-lexical-rerank-v1",
request_id=None,
usage=ProviderUsage(
input_tokens=len(query_features)
+ sum(len(lexical_features(document)) for document in documents)
),
elapsed_ms=(time.perf_counter() - started) * 1000,
)

View File

@@ -1 +0,0 @@

View File

@@ -0,0 +1 @@
"""Configuration and security primitives."""

128
backend/app/core/config.py Normal file
View File

@@ -0,0 +1,128 @@
"""Typed runtime configuration loaded from non-secret environment values."""
from functools import lru_cache
from pathlib import Path
from typing import Literal, Self
from urllib.parse import urlsplit
from pydantic import Field, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from sqlalchemy import URL
from app.core.secrets import read_secret_file
class Settings(BaseSettings):
"""Application settings; secret values stay in mounted files."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
app_env: Literal["development", "test", "production"] = "development"
app_name: str = "geological-rag"
app_secret_key_file: Path = Path("/run/secrets/app_secret_key")
postgres_host: str = "db"
postgres_port: int = Field(default=5432, ge=1, le=65535)
postgres_db: str = "geological_rag"
postgres_user: str = "geological_rag_app"
postgres_password_file: Path = Path("/run/secrets/postgres_app_password")
upload_root: Path = Path("/data/uploads")
max_upload_mb: int = Field(default=100, ge=1, le=2048)
bailian_openai_base_url: str = (
"https://<workspace-id>.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"
)
bailian_native_base_url: str = "https://<workspace-id>.cn-beijing.maas.aliyuncs.com/api/v1"
bailian_rerank_base_url: str = (
"https://<workspace-id>.cn-beijing.maas.aliyuncs.com/compatible-api/v1"
)
dashscope_api_key_file: Path = Path("/run/secrets/bailian_api_key")
embedding_model: str = "text-embedding-v4"
embedding_dimension: Literal[1024] = 1024
rerank_model: str = "qwen3-rerank"
llm_model: str = "deepseek-v4-flash"
chunk_target_tokens: int = Field(default=512, ge=120, le=800)
chunk_max_tokens: int = Field(default=800, ge=120, le=4000)
chunk_overlap_tokens: int = Field(default=64, ge=0, le=256)
vector_top_k: int = Field(default=50, ge=1, le=500)
rerank_top_n: int = Field(default=10, ge=1, le=500)
context_top_n: int = Field(default=8, ge=1, le=50)
max_context_tokens: int = Field(default=10_000, ge=1, le=100_000)
model_timeout_seconds: float = Field(default=90, gt=0, le=600)
model_max_retries: int = Field(default=3, ge=0, le=10)
model_max_concurrency: int = Field(default=4, ge=1, le=100)
worker_capabilities: str = "document_parse,embedding,rerank,evaluation"
@field_validator(
"bailian_openai_base_url",
"bailian_native_base_url",
"bailian_rerank_base_url",
)
@classmethod
def normalize_base_url(cls, value: str) -> str:
return value.rstrip("/")
@model_validator(mode="after")
def validate_rag_limits(self) -> Self:
if self.chunk_overlap_tokens >= self.chunk_target_tokens:
raise ValueError("CHUNK_OVERLAP_TOKENS must be smaller than CHUNK_TARGET_TOKENS")
if self.chunk_target_tokens > self.chunk_max_tokens:
raise ValueError("CHUNK_TARGET_TOKENS must not exceed CHUNK_MAX_TOKENS")
if self.context_top_n > self.rerank_top_n:
raise ValueError("CONTEXT_TOP_N must not exceed RERANK_TOP_N")
if self.rerank_top_n > self.vector_top_k:
raise ValueError("RERANK_TOP_N must not exceed VECTOR_TOP_K")
return self
def bailian_api_key(self) -> str:
self.validate_live_bailian_endpoints()
return read_secret_file(self.dashscope_api_key_file)
def validate_live_bailian_endpoints(self) -> str:
endpoints = {
self.bailian_openai_base_url: "/compatible-mode/v1",
self.bailian_rerank_base_url: "/compatible-api/v1",
}
hosts: set[str] = set()
for endpoint, expected_path in endpoints.items():
parsed = urlsplit(endpoint)
host = parsed.hostname or ""
if (
parsed.scheme != "https"
or parsed.path.rstrip("/") != expected_path
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
or "<" in endpoint
or not host.endswith(".cn-beijing.maas.aliyuncs.com")
):
raise ValueError("Bailian endpoint is not an approved Beijing MaaS URL")
hosts.add(host)
if len(hosts) != 1:
raise ValueError("Bailian endpoints must use the same workspace host")
return hosts.pop()
def database_url(self) -> URL:
return URL.create(
drivername="postgresql+psycopg",
username=self.postgres_user,
password=read_secret_file(self.postgres_password_file),
host=self.postgres_host,
port=self.postgres_port,
database=self.postgres_db,
)
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()

View File

@@ -0,0 +1,24 @@
"""Secret-file helpers that never include secret values in errors or reprs."""
from pathlib import Path
class SecretFileError(RuntimeError):
"""Raised when a configured secret file cannot be read safely."""
def read_secret_file(path: Path) -> str:
"""Read one Docker/Kubernetes secret without exposing its content."""
try:
if not path.is_file():
raise SecretFileError(f"Secret file is missing or not a file: {path}")
value = path.read_text(encoding="utf-8").strip()
except OSError as exc:
raise SecretFileError(f"Secret file cannot be read: {path}") from exc
if not value:
raise SecretFileError(f"Secret file is empty: {path}")
if "\n" in value or "\r" in value:
raise SecretFileError(f"Secret file must contain exactly one logical line: {path}")
return value

56
backend/app/main.py Normal file
View File

@@ -0,0 +1,56 @@
"""Minimal FastAPI entrypoint; product endpoints are added in stage 2."""
from typing import Any
import psycopg
import uvicorn
from fastapi import FastAPI, HTTPException, status
from app import __version__
from app.core.config import get_settings
from app.core.secrets import SecretFileError
app = FastAPI(title="Geological RAG API", version=__version__)
@app.get("/api/v1/health/live", tags=["health"])
def live() -> dict[str, str]:
return {"status": "ok", "version": __version__}
@app.get("/api/v1/health/ready", tags=["health"])
def ready() -> dict[str, str]:
settings = get_settings()
try:
dsn = settings.database_url().set(drivername="postgresql")
with psycopg.connect(
dsn.render_as_string(hide_password=False),
connect_timeout=2,
) as connection:
connection.execute("SELECT 1")
except (OSError, SecretFileError, psycopg.Error) as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="database unavailable",
) from exc
return {"status": "ready"}
@app.get("/api/v1/meta", tags=["meta"])
def meta() -> dict[str, Any]:
settings = get_settings()
return {
"name": settings.app_name,
"environment": settings.app_env,
"version": __version__,
"models": {
"embedding": settings.embedding_model,
"rerank": settings.rerank_model,
"generation": settings.llm_model,
},
}
if __name__ == "__main__":
# Container ingress is controlled by Compose; only the edge proxy publishes a port.
uvicorn.run("app.main:app", host="0.0.0.0", port=8000) # noqa: S104

View File

@@ -1 +0,0 @@

View File

@@ -0,0 +1,17 @@
"""Persistence-level contracts shared by workers and integration tests."""
from .job_queue_sql import (
CLAIM_JOB_SQL,
COMPLETE_JOB_SQL,
FAIL_OR_RETRY_JOB_SQL,
HEARTBEAT_JOB_SQL,
REAP_EXPIRED_JOBS_SQL,
)
__all__ = [
"CLAIM_JOB_SQL",
"COMPLETE_JOB_SQL",
"FAIL_OR_RETRY_JOB_SQL",
"HEARTBEAT_JOB_SQL",
"REAP_EXPIRED_JOBS_SQL",
]

View File

@@ -0,0 +1,129 @@
"""Fenced PostgreSQL job-queue statements.
Callers must execute each statement in a transaction and treat an empty
``RETURNING`` result as loss of the lease. No external model call should hold
the claim transaction open.
"""
CLAIM_JOB_SQL = """
WITH candidate AS (
SELECT job.id
FROM rag.background_jobs AS job
WHERE job.attempt < job.max_attempts
AND job.required_capability = ANY(CAST(:worker_capabilities AS text[]))
AND (
(job.status = 'QUEUED' AND job.run_after <= now())
OR (job.status = 'RUNNING' AND job.lease_until < now())
)
ORDER BY job.priority DESC, job.run_after, job.created_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE rag.background_jobs AS job
SET status = 'RUNNING',
lease_owner = :worker_id,
lease_token = gen_random_uuid(),
lease_until = now() + make_interval(secs => CAST(:lease_seconds AS integer)),
attempt = job.attempt + 1,
updated_at = now(),
finished_at = NULL
FROM candidate
WHERE job.id = candidate.id
RETURNING job.*
"""
HEARTBEAT_JOB_SQL = """
UPDATE rag.background_jobs AS job
SET lease_until = now() + make_interval(secs => CAST(:lease_seconds AS integer)),
updated_at = now()
WHERE job.id = :job_id
AND job.status = 'RUNNING'
AND job.lease_owner = :worker_id
AND job.lease_token = :lease_token
RETURNING job.id, job.lease_until
"""
COMPLETE_JOB_SQL = """
UPDATE rag.background_jobs AS job
SET status = 'SUCCEEDED',
progress = 100,
lease_owner = NULL,
lease_token = NULL,
lease_until = NULL,
finished_at = now(),
updated_at = now()
WHERE job.id = :job_id
AND job.status = 'RUNNING'
AND job.lease_owner = :worker_id
AND job.lease_token = :lease_token
RETURNING job.*
"""
FAIL_OR_RETRY_JOB_SQL = """
UPDATE rag.background_jobs AS job
SET status = CASE
WHEN job.attempt < job.max_attempts THEN 'QUEUED'
ELSE 'FAILED'
END,
run_after = CASE
WHEN job.attempt < job.max_attempts
THEN now() + make_interval(
secs => GREATEST(CAST(:retry_delay_seconds AS integer), 0)
)
ELSE job.run_after
END,
last_error_code = :error_code,
last_error_message = left(:error_message, 2000),
lease_owner = NULL,
lease_token = NULL,
lease_until = NULL,
finished_at = CASE
WHEN job.attempt < job.max_attempts THEN NULL
ELSE now()
END,
updated_at = now()
WHERE job.id = :job_id
AND job.status = 'RUNNING'
AND job.lease_owner = :worker_id
AND job.lease_token = :lease_token
RETURNING job.*
"""
REAP_EXPIRED_JOBS_SQL = """
WITH maintenance_lock AS (
SELECT pg_try_advisory_xact_lock(CAST(:lock_key AS bigint)) AS acquired
),
expired AS (
SELECT job.id
FROM rag.background_jobs AS job
CROSS JOIN maintenance_lock
WHERE maintenance_lock.acquired
AND job.status = 'RUNNING'
AND job.lease_until < now()
ORDER BY job.lease_until, job.created_at
FOR UPDATE OF job SKIP LOCKED
LIMIT CAST(:batch_size AS integer)
)
UPDATE rag.background_jobs AS job
SET status = CASE
WHEN job.attempt < job.max_attempts THEN 'QUEUED'
ELSE 'FAILED'
END,
run_after = CASE
WHEN job.attempt < job.max_attempts THEN now()
ELSE job.run_after
END,
last_error_code = 'WORKER_LEASE_EXPIRED',
last_error_message = 'Worker lease expired before a fenced terminal update.',
lease_owner = NULL,
lease_token = NULL,
lease_until = NULL,
finished_at = CASE
WHEN job.attempt < job.max_attempts THEN NULL
ELSE now()
END,
updated_at = now()
FROM expired
WHERE job.id = expired.id
RETURNING job.*
"""

View File

@@ -1 +0,0 @@

View File

@@ -0,0 +1,151 @@
"""Dependency-inversion ports for external model providers.
The domain and service layers depend on these provider-neutral value objects and
protocols. Vendor SDK/HTTP response objects must not cross this boundary.
"""
from __future__ import annotations
from collections.abc import AsyncIterator, Callable, Sequence
from dataclasses import dataclass
from enum import StrEnum
from typing import Protocol
TokenCounter = Callable[[str], int]
class ProviderErrorKind(StrEnum):
"""Stable, provider-neutral error categories."""
INVALID_REQUEST = "invalid_request"
AUTHENTICATION = "authentication"
PERMISSION_DENIED = "permission_denied"
NOT_FOUND = "not_found"
RATE_LIMITED = "rate_limited"
TIMEOUT = "timeout"
TRANSPORT = "transport"
UPSTREAM = "upstream"
INVALID_RESPONSE = "invalid_response"
class ModelProviderError(RuntimeError):
"""Sanitized provider failure safe for structured application handling.
The exception intentionally never stores request payloads, response bodies,
HTTP request objects, headers, or credentials.
"""
def __init__(
self,
*,
operation: str,
kind: ProviderErrorKind,
status_code: int | None = None,
provider_code: str | None = None,
request_id: str | None = None,
retryable: bool = False,
) -> None:
self.operation = operation
self.kind = kind
self.status_code = status_code
self.provider_code = provider_code
self.request_id = request_id
self.retryable = retryable
details = [f"kind={kind.value}"]
if status_code is not None:
details.append(f"status={status_code}")
if provider_code is not None:
details.append(f"code={provider_code}")
super().__init__(f"model provider {operation} failed ({', '.join(details)})")
@dataclass(frozen=True, slots=True)
class ProviderUsage:
input_tokens: int | None = None
output_tokens: int | None = None
total_tokens: int | None = None
@dataclass(frozen=True, slots=True)
class EmbeddingResult:
vectors: tuple[tuple[float, ...], ...]
model: str
request_id: str | None
usage: ProviderUsage
elapsed_ms: float
@dataclass(frozen=True, slots=True)
class RankedItem:
index: int
relevance_score: float
document: str
@dataclass(frozen=True, slots=True)
class RerankResult:
items: tuple[RankedItem, ...]
model: str
request_id: str | None
usage: ProviderUsage
elapsed_ms: float
@dataclass(frozen=True, slots=True)
class ChatMessage:
role: str
content: str
@dataclass(frozen=True, slots=True)
class ChatCompletionResult:
content: str
finish_reason: str | None
model: str
request_id: str | None
usage: ProviderUsage
elapsed_ms: float
@dataclass(frozen=True, slots=True)
class ChatStreamEvent:
delta: str
finish_reason: str | None
model: str
request_id: str | None
usage: ProviderUsage
elapsed_ms: float
class EmbeddingProvider(Protocol):
async def embed_documents(self, texts: Sequence[str]) -> EmbeddingResult: ...
async def embed_query(self, text: str) -> EmbeddingResult: ...
class Reranker(Protocol):
async def rerank(
self,
query: str,
documents: Sequence[str],
*,
top_n: int,
instruct: str | None = None,
) -> RerankResult: ...
class ChatProvider(Protocol):
async def complete(
self,
messages: Sequence[ChatMessage],
*,
max_tokens: int,
) -> ChatCompletionResult: ...
def stream(
self,
messages: Sequence[ChatMessage],
*,
max_tokens: int,
) -> AsyncIterator[ChatStreamEvent]: ...

View File

@@ -1 +0,0 @@

View File

@@ -0,0 +1 @@
"""Operational command entrypoints shipped in the backend image."""

View File

@@ -0,0 +1,190 @@
"""Minimal live probes for the three configured Bailian capabilities."""
from __future__ import annotations
import asyncio
import json
import sys
from collections.abc import Awaitable, Callable
from dataclasses import asdict, dataclass
from typing import Any
from app.adapters.bailian import (
BailianChatAdapter,
BailianEmbeddingAdapter,
BailianRerankerAdapter,
)
from app.core.config import Settings
from app.core.secrets import SecretFileError
from app.ports.model_providers import ChatMessage, ModelProviderError
@dataclass(frozen=True, slots=True)
class ProbeResult:
capability: str
status: str
model: str | None = None
elapsed_ms: float | None = None
request_id: str | None = None
error_kind: str | None = None
status_code: int | None = None
async def probe_embedding(settings: Settings, api_key: str) -> ProbeResult:
adapter = BailianEmbeddingAdapter(
api_key=api_key,
base_url=settings.bailian_openai_base_url,
model=settings.embedding_model,
dimensions=settings.embedding_dimension,
timeout_seconds=settings.model_timeout_seconds,
max_retries=settings.model_max_retries,
)
try:
result = await adapter.embed_documents(["用于能力探测的虚构地质文本。"])
if len(result.vectors) != 1 or len(result.vectors[0]) != settings.embedding_dimension:
raise RuntimeError("embedding contract mismatch")
return ProbeResult(
capability="embedding",
status="ok",
model=result.model,
elapsed_ms=round(result.elapsed_ms, 2),
request_id=result.request_id,
)
finally:
await adapter.aclose()
async def probe_rerank(settings: Settings, api_key: str) -> ProbeResult:
adapter = BailianRerankerAdapter(
api_key=api_key,
base_url=settings.bailian_rerank_base_url,
model=settings.rerank_model,
timeout_seconds=settings.model_timeout_seconds,
max_retries=settings.model_max_retries,
)
try:
result = await adapter.rerank(
"哪段文本提到了斑岩铜矿?",
["虚构斑岩铜矿具有钾化带。", "虚构煤层采用测井曲线对比。"],
top_n=1,
)
if len(result.items) != 1 or result.items[0].index not in (0, 1):
raise RuntimeError("rerank contract mismatch")
return ProbeResult(
capability="rerank",
status="ok",
model=result.model,
elapsed_ms=round(result.elapsed_ms, 2),
request_id=result.request_id,
)
finally:
await adapter.aclose()
async def probe_chat(settings: Settings, api_key: str) -> ProbeResult:
adapter = BailianChatAdapter(
api_key=api_key,
base_url=settings.bailian_openai_base_url,
model=settings.llm_model,
timeout_seconds=settings.model_timeout_seconds,
max_retries=settings.model_max_retries,
)
model: str | None = None
request_id: str | None = None
elapsed_ms = 0.0
content_seen = False
try:
async for event in adapter.stream(
[ChatMessage(role="user", content="只回复:能力正常")],
max_tokens=16,
):
model = event.model
request_id = event.request_id or request_id
elapsed_ms = max(elapsed_ms, event.elapsed_ms)
content_seen = content_seen or bool(event.delta)
if not content_seen:
raise RuntimeError("chat stream contained no text")
return ProbeResult(
capability="chat",
status="ok",
model=model,
elapsed_ms=round(elapsed_ms, 2),
request_id=request_id,
)
finally:
await adapter.aclose()
def failed_probe(capability: str, error: BaseException) -> ProbeResult:
if isinstance(error, ModelProviderError):
return ProbeResult(
capability=capability,
status="failed",
request_id=error.request_id,
error_kind=error.kind.value,
status_code=error.status_code,
)
return ProbeResult(
capability=capability,
status="failed",
error_kind="internal_contract_error",
)
async def run_probe(
capability: str,
operation: Callable[[Settings, str], Awaitable[ProbeResult]],
settings: Settings,
api_key: str,
) -> ProbeResult:
try:
return await operation(settings, api_key)
except Exception as exc: # The output is deliberately reduced to a safe category.
return failed_probe(capability, exc)
def write_json_line(payload: dict[str, Any]) -> None:
sys.stdout.write(json.dumps(payload, ensure_ascii=False, sort_keys=True) + "\n")
async def async_main() -> int:
try:
settings = Settings()
if any(
"<workspace-id>" in url
for url in (
settings.bailian_openai_base_url,
settings.bailian_rerank_base_url,
)
):
raise ValueError("workspace endpoint placeholders are not runnable")
api_key = settings.bailian_api_key()
except (SecretFileError, ValueError):
write_json_line(
{
"capability": "configuration",
"status": "failed",
"error_kind": "invalid_local_configuration",
}
)
return 2
probes = (
("embedding", probe_embedding),
("rerank", probe_rerank),
("chat", probe_chat),
)
results = []
for capability, operation in probes:
result = await run_probe(capability, operation, settings, api_key)
results.append(result)
write_json_line(asdict(result))
return 0 if all(result.status == "ok" for result in results) else 1
def main() -> None:
raise SystemExit(asyncio.run(async_main()))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,666 @@
"""Idempotently embed, store, retrieve, and rerank the public synthetic corpus."""
from __future__ import annotations
import asyncio
import hashlib
import json
import os
import sys
import uuid
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any, cast
from urllib.parse import urlsplit
import psycopg
from pgvector import Vector
from pgvector.psycopg import register_vector
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from app.adapters.bailian import BailianEmbeddingAdapter, BailianRerankerAdapter
from app.adapters.fake import FakeEmbeddingProvider, FakeReranker, lexical_features
from app.core.config import Settings
from app.core.secrets import SecretFileError
from app.ports.model_providers import EmbeddingProvider, ModelProviderError, Reranker
PROJECT_ROOT = Path(__file__).resolve().parents[3]
DEFAULT_SAMPLE_ROOT = PROJECT_ROOT / "data" / "samples" / "public"
IDENTITY_NAMESPACE = uuid.UUID("eef85571-1f64-4a09-86d7-53fd329c3eb2")
KNOWLEDGE_BASE_ID = uuid.uuid5(IDENTITY_NAMESPACE, "synthetic-demo-knowledge-base")
ACCESS_SCOPE_ID = uuid.uuid5(IDENTITY_NAMESPACE, "synthetic-demo-public-scope")
@dataclass(frozen=True, slots=True)
class DemoDocument:
source_id: str
title: str
content: str
region: str
mineral: str
page_no: int
cloud_policy_id: str
@dataclass(frozen=True, slots=True)
class DemoQuery:
qid: str
query: str
expected_doc_ids: tuple[str, ...]
answerable: bool
@dataclass(frozen=True, slots=True)
class PreparedChunk:
source_id: str
document_id: uuid.UUID
version_id: uuid.UUID
chunk_id: uuid.UUID
raw_sha256: str
cloud_text: str
cloud_text_sha256: str
embedding_prefix: str
embedding_text: str
embedding_text_sha256: str
outbound_manifest_sha256: str
embedding_profile_hash: str
vector: tuple[float, ...]
embedding_model: str
title: str
region: str
mineral: str
page_no: int
cloud_policy_id: str
class SeedContractError(ValueError):
def __init__(self, code: str) -> None:
self.code = code
super().__init__(code)
def sha256_text(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def load_jsonl(path: Path) -> list[dict[str, Any]]:
if not path.is_file():
raise SeedContractError("fixture_missing")
records: list[dict[str, Any]] = []
for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
try:
value = json.loads(line)
except json.JSONDecodeError as exc:
raise SeedContractError(f"invalid_jsonl_line_{line_number}") from exc
if not isinstance(value, dict):
raise SeedContractError(f"jsonl_object_required_line_{line_number}")
records.append(value)
return records
def load_documents(path: Path) -> list[DemoDocument]:
documents = []
for value in load_jsonl(path):
if value.get("source_type") != "synthetic":
raise SeedContractError("non_synthetic_document")
if value.get("review_state") != "LOCAL_PARSED_PENDING_CLOUD_REVIEW":
raise SeedContractError("invalid_initial_review_state")
documents.append(
DemoDocument(
source_id=str(value["doc_id"]),
title=str(value["title"]),
content=str(value["content"]),
region=str(value["region"]),
mineral=str(value["mineral"]),
page_no=int(value["page_no"]),
cloud_policy_id=str(value["cloud_policy_id"]),
)
)
if len(documents) != 20 or len({item.source_id for item in documents}) != 20:
raise SeedContractError("expected_twenty_unique_documents")
return documents
def load_queries(path: Path) -> list[DemoQuery]:
queries = [
DemoQuery(
qid=str(value["qid"]),
query=str(value["query"]),
expected_doc_ids=tuple(str(item) for item in value["expected_doc_ids"]),
answerable=bool(value["answerable"]),
)
for value in load_jsonl(path)
]
if not queries:
raise SeedContractError("query_set_empty")
return queries
def embedding_profile_hash(settings: Settings, mode: str) -> str:
endpoint_identity = "local-fake"
model = "fake-feature-hash-v1"
api_mode = "deterministic-offline"
if mode == "bailian":
endpoint_identity = sha256_text(urlsplit(settings.bailian_openai_base_url).hostname or "")
model = settings.embedding_model
api_mode = "openai-compatible"
profile = {
"api_mode": api_mode,
"dimension": settings.embedding_dimension,
"endpoint_identity_hash": endpoint_identity,
"model": model,
"normalization": "provider-default",
"profile_version": 1,
}
return sha256_text(
json.dumps(profile, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
)
async def embed_in_batches(
provider: EmbeddingProvider,
texts: Sequence[str],
) -> tuple[tuple[tuple[float, ...], ...], str]:
vectors: list[tuple[float, ...]] = []
resolved_model: str | None = None
for offset in range(0, len(texts), 10):
result = await provider.embed_documents(texts[offset : offset + 10])
if resolved_model is not None and result.model != resolved_model:
raise SeedContractError("embedding_model_changed_between_batches")
resolved_model = result.model
vectors.extend(result.vectors)
if len(vectors) != len(texts) or resolved_model is None:
raise SeedContractError("embedding_result_count_mismatch")
return tuple(vectors), resolved_model
def prepare_chunks(
documents: Sequence[DemoDocument],
vectors: Sequence[tuple[float, ...]],
*,
profile_hash: str,
embedding_model: str,
) -> list[PreparedChunk]:
prepared = []
for document, vector in zip(documents, vectors, strict=True):
raw_payload = json.dumps(
{
"content": document.content,
"mineral": document.mineral,
"page_no": document.page_no,
"region": document.region,
"title": document.title,
},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
raw_hash = sha256_text(raw_payload)
document_id = uuid.uuid5(IDENTITY_NAMESPACE, f"document:{document.source_id}")
version_id = uuid.uuid5(
IDENTITY_NAMESPACE,
f"version:{document.source_id}:{raw_hash}:{profile_hash}",
)
chunk_id = uuid.uuid5(IDENTITY_NAMESPACE, f"chunk:{version_id}:0")
prefix = (
f"标题:{document.title}\n地区:{document.region}\n矿种:{document.mineral}\n正文:"
)
cloud_hash = sha256_text(document.content)
embedding_text = prefix + document.content
embedding_hash = sha256_text(embedding_text)
manifest_payload = json.dumps(
[
{
"cloud_text_sha256": cloud_hash,
"embedding_text_sha256": embedding_hash,
"ordinal": 0,
}
],
sort_keys=True,
separators=(",", ":"),
)
prepared.append(
PreparedChunk(
source_id=document.source_id,
document_id=document_id,
version_id=version_id,
chunk_id=chunk_id,
raw_sha256=raw_hash,
cloud_text=document.content,
cloud_text_sha256=cloud_hash,
embedding_prefix=prefix,
embedding_text=embedding_text,
embedding_text_sha256=embedding_hash,
outbound_manifest_sha256=sha256_text(manifest_payload),
embedding_profile_hash=profile_hash,
vector=vector,
embedding_model=embedding_model,
title=document.title,
region=document.region,
mineral=document.mineral,
page_no=document.page_no,
cloud_policy_id=document.cloud_policy_id,
)
)
return prepared
def database_dsn(settings: Settings) -> str:
return (
settings.database_url().set(drivername="postgresql").render_as_string(hide_password=False)
)
def write_chunks(settings: Settings, chunks: Sequence[PreparedChunk]) -> dict[str, int]:
with psycopg.connect(database_dsn(settings), row_factory=dict_row) as connection:
register_vector(connection)
connection.execute("SELECT pg_advisory_xact_lock(724202607120001)")
connection.execute(
"""
INSERT INTO rag.knowledge_bases (id, name, description)
VALUES (%s, %s, %s)
ON CONFLICT (id) DO UPDATE
SET name = EXCLUDED.name,
description = EXCLUDED.description,
updated_at = now()
""",
(KNOWLEDGE_BASE_ID, "虚构地质 PoC 知识库", "仅含公开的合成验证文本"),
)
connection.execute(
"""
INSERT INTO rag.access_scopes (id, knowledge_base_id, name)
VALUES (%s, %s, %s)
ON CONFLICT (id) DO NOTHING
""",
(ACCESS_SCOPE_ID, KNOWLEDGE_BASE_ID, "synthetic-demo"),
)
for item in chunks:
connection.execute(
"""
INSERT INTO rag.documents (
id, knowledge_base_id, access_scope_id, raw_sha256,
filename, storage_key, mime_type, status
) VALUES (%s, %s, %s, %s, %s, %s, 'application/json',
'LOCAL_PARSED_PENDING_CLOUD_REVIEW')
ON CONFLICT (id) DO UPDATE
SET filename = EXCLUDED.filename,
storage_key = EXCLUDED.storage_key,
mime_type = EXCLUDED.mime_type,
updated_at = now()
""",
(
item.document_id,
KNOWLEDGE_BASE_ID,
ACCESS_SCOPE_ID,
item.raw_sha256,
f"{item.source_id}.json",
f"synthetic/{item.source_id}",
),
)
connection.execute(
"""
INSERT INTO rag.document_versions (
id, document_id, parser_profile_hash, ocr_profile_hash,
normalization_profile_hash, chunk_profile_hash, cloud_policy_id,
outbound_manifest_sha256, review_state, embedding_profile_hash,
status, expected_chunk_count
) VALUES (
%s, %s, %s, NULL, %s, %s, %s, %s,
'LOCAL_PARSED_PENDING_CLOUD_REVIEW', %s, 'PROCESSING', 1
)
ON CONFLICT (id) DO NOTHING
""",
(
item.version_id,
item.document_id,
sha256_text("synthetic-jsonl-parser-v1"),
sha256_text("identity-normalization-v1"),
sha256_text("one-record-one-chunk-v1"),
item.cloud_policy_id,
item.outbound_manifest_sha256,
item.embedding_profile_hash,
),
)
connection.execute(
"""
INSERT INTO rag.outbound_manifest_items (
document_version_id, ordinal, outbound_manifest_sha256,
cloud_text_sha256, embedding_text_sha256
)
SELECT %s, 0, %s, %s, %s
WHERE NOT EXISTS (
SELECT 1
FROM rag.outbound_manifest_items
WHERE document_version_id = %s AND ordinal = 0
)
ON CONFLICT (document_version_id, ordinal) DO NOTHING
""",
(
item.version_id,
item.outbound_manifest_sha256,
item.cloud_text_sha256,
item.embedding_text_sha256,
item.version_id,
),
)
connection.execute(
"""
INSERT INTO rag.chunks (
id, knowledge_base_id, document_id, document_version_id,
access_scope_id, ordinal, display_text, cloud_text,
cloud_text_sha256, embedding_prefix, embedding_text,
embedding_text_sha256, embedded_text_sha256,
embedding_profile_hash, outbound_manifest_sha256, token_count,
page_start, page_end, section_path, metadata, embedding_model,
embedding_dimension, embedding, approval_status, index_status,
searchable
) VALUES (
%s, %s, %s, %s, %s, 0, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, 1024, %s,
'LOCAL_PARSED_PENDING_CLOUD_REVIEW', 'READY', false
)
ON CONFLICT (id) DO UPDATE SET
display_text = EXCLUDED.display_text,
cloud_text = EXCLUDED.cloud_text,
cloud_text_sha256 = EXCLUDED.cloud_text_sha256,
embedding_prefix = EXCLUDED.embedding_prefix,
embedding_text = EXCLUDED.embedding_text,
embedding_text_sha256 = EXCLUDED.embedding_text_sha256,
embedded_text_sha256 = EXCLUDED.embedded_text_sha256,
embedding_profile_hash = EXCLUDED.embedding_profile_hash,
outbound_manifest_sha256 = EXCLUDED.outbound_manifest_sha256,
token_count = EXCLUDED.token_count,
page_start = EXCLUDED.page_start,
page_end = EXCLUDED.page_end,
section_path = EXCLUDED.section_path,
metadata = EXCLUDED.metadata,
embedding_model = EXCLUDED.embedding_model,
updated_at = now()
""",
(
item.chunk_id,
KNOWLEDGE_BASE_ID,
item.document_id,
item.version_id,
ACCESS_SCOPE_ID,
item.cloud_text,
item.cloud_text,
item.cloud_text_sha256,
item.embedding_prefix,
item.embedding_text,
item.embedding_text_sha256,
item.embedding_text_sha256,
item.embedding_profile_hash,
item.outbound_manifest_sha256,
max(1, len(lexical_features(item.embedding_text))),
item.page_no,
item.page_no,
Jsonb([item.title]),
Jsonb(
{
"mineral": item.mineral,
"region": item.region,
"source_doc_id": item.source_id,
"source_type": "synthetic",
}
),
item.embedding_model,
Vector(list(item.vector)),
),
)
connection.execute(
"""
UPDATE rag.document_versions
SET review_state = 'CLOUD_APPROVED',
cloud_approved_at = COALESCE(cloud_approved_at, now()),
cloud_approved_by = 'seed-demo:synthetic-policy',
status = 'READY',
completed_at = COALESCE(completed_at, now())
WHERE id = %s
""",
(item.version_id,),
)
connection.execute(
"""
UPDATE rag.chunks
SET approval_status = 'CLOUD_APPROVED',
index_status = 'READY',
updated_at = now()
WHERE id = %s
""",
(item.chunk_id,),
)
connection.execute(
"""
UPDATE rag.documents
SET active_version_id = %s,
raw_sha256 = %s,
status = 'READY',
updated_at = now()
WHERE id = %s
""",
(item.version_id, item.raw_sha256, item.document_id),
)
connection.execute(
"UPDATE rag.chunks SET searchable = true, updated_at = now() WHERE id = %s",
(item.chunk_id,),
)
counts = connection.execute(
"""
SELECT
count(*)::integer AS chunks,
count(*) FILTER (WHERE embedding IS NOT NULL)::integer AS vectors,
count(*) FILTER (WHERE searchable)::integer AS searchable
FROM rag.chunks
WHERE knowledge_base_id = %s
""",
(KNOWLEDGE_BASE_ID,),
).fetchone()
if counts is None:
raise SeedContractError("database_count_missing")
return {key: int(counts[key]) for key in ("chunks", "vectors", "searchable")}
def retrieve(
settings: Settings,
query_vector: tuple[float, ...],
) -> list[dict[str, Any]]:
with psycopg.connect(database_dsn(settings), row_factory=dict_row) as connection:
register_vector(connection)
connection.execute("SET LOCAL hnsw.iterative_scan = strict_order")
connection.execute("SET LOCAL hnsw.ef_search = 100")
rows = connection.execute(
"""
SELECT id, metadata, embedding_text,
1 - (embedding <=> %s) AS vector_score
FROM rag.chunks
WHERE searchable
AND knowledge_base_id = %s
AND access_scope_id = %s
ORDER BY embedding <=> %s
LIMIT %s
""",
(
Vector(list(query_vector)),
KNOWLEDGE_BASE_ID,
ACCESS_SCOPE_ID,
Vector(list(query_vector)),
settings.vector_top_k,
),
).fetchall()
return [dict(row) for row in rows]
async def evaluate_queries(
settings: Settings,
queries: Sequence[DemoQuery],
embedder: EmbeddingProvider,
reranker: Reranker,
) -> dict[str, float | int]:
hits = 0
answerable = 0
for query in queries:
query_result = await embedder.embed_query(query.query)
candidates = retrieve(settings, query_result.vectors[0])
if not candidates:
continue
reranked = await reranker.rerank(
query.query,
[cast(str, item["embedding_text"]) for item in candidates],
top_n=min(settings.rerank_top_n, len(candidates)),
)
result_doc_ids = [
cast(dict[str, Any], candidates[item.index]["metadata"])["source_doc_id"]
for item in reranked.items[:3]
]
if query.answerable:
answerable += 1
if set(query.expected_doc_ids) & set(result_doc_ids):
hits += 1
return {
"answerable_queries": answerable,
"hit_at_3": hits,
"hit_rate_at_3": round(hits / answerable, 4) if answerable else 0.0,
}
def output_summary(payload: dict[str, Any]) -> None:
sys.stdout.write(json.dumps(payload, ensure_ascii=False, sort_keys=True) + "\n")
def safe_failure_site(error: BaseException) -> str:
traceback = error.__traceback__
selected: str | None = None
while traceback is not None:
filename = Path(traceback.tb_frame.f_code.co_filename).name
if filename == "seed_demo.py":
selected = f"{filename}:{traceback.tb_frame.f_code.co_name}:{traceback.tb_lineno}"
traceback = traceback.tb_next
return selected or "external_dependency"
async def async_main() -> int:
mode = os.getenv("DEMO_PROVIDER_MODE", "fake").strip().lower()
if mode not in {"fake", "bailian"}:
output_summary({"status": "failed", "error_kind": "invalid_provider_mode"})
return 2
settings = Settings()
documents_path = Path(
os.getenv("DEMO_DOCUMENTS_PATH", str(DEFAULT_SAMPLE_ROOT / "demo_documents.jsonl"))
)
queries_path = Path(
os.getenv("DEMO_QUERIES_PATH", str(DEFAULT_SAMPLE_ROOT / "demo_queries.jsonl"))
)
cloud_embedder: BailianEmbeddingAdapter | None = None
cloud_reranker: BailianRerankerAdapter | None = None
try:
documents = load_documents(documents_path)
queries = load_queries(queries_path)
profile_hash = embedding_profile_hash(settings, mode)
embedder: EmbeddingProvider
reranker: Reranker
if mode == "bailian":
api_key = settings.bailian_api_key()
cloud_embedder = BailianEmbeddingAdapter(
api_key=api_key,
base_url=settings.bailian_openai_base_url,
model=settings.embedding_model,
dimensions=settings.embedding_dimension,
timeout_seconds=settings.model_timeout_seconds,
max_retries=settings.model_max_retries,
)
cloud_reranker = BailianRerankerAdapter(
api_key=api_key,
base_url=settings.bailian_rerank_base_url,
model=settings.rerank_model,
timeout_seconds=settings.model_timeout_seconds,
max_retries=settings.model_max_retries,
)
embedder = cloud_embedder
reranker = cloud_reranker
else:
embedder = FakeEmbeddingProvider(settings.embedding_dimension)
reranker = FakeReranker()
texts = [
f"标题:{item.title}\n地区:{item.region}\n矿种:{item.mineral}\n正文:{item.content}"
for item in documents
]
vectors, resolved_model = await embed_in_batches(embedder, texts)
prepared = prepare_chunks(
documents,
vectors,
profile_hash=profile_hash,
embedding_model=resolved_model,
)
counts = write_chunks(settings, prepared)
metrics = await evaluate_queries(settings, queries, embedder, reranker)
output_summary(
{
"counts": counts,
"embedding_model": resolved_model,
"metrics": metrics,
"provider_mode": mode,
"status": "ok",
}
)
return 0
except ModelProviderError as exc:
output_summary(
{
"status": "failed",
"error_kind": f"model_provider_{exc.kind.value}",
"status_code": exc.status_code,
}
)
return 1
except psycopg.Error as exc:
constraint_name = exc.diag.constraint_name
output_summary(
{
"status": "failed",
"error_kind": "database_error",
"sqlstate": exc.sqlstate,
"failure_site": safe_failure_site(exc),
"constraint": constraint_name
if constraint_name and constraint_name.replace("_", "").isalnum()
else None,
}
)
return 1
except SecretFileError:
output_summary({"status": "failed", "error_kind": "secret_configuration"})
return 1
except OSError:
output_summary({"status": "failed", "error_kind": "fixture_io_error"})
return 1
except SeedContractError as exc:
output_summary({"status": "failed", "error_kind": "seed_contract_error", "code": exc.code})
return 1
except ValueError as exc:
output_summary(
{
"status": "failed",
"error_kind": "fixture_or_contract_error",
"failure_site": safe_failure_site(exc),
}
)
return 1
finally:
if cloud_embedder is not None:
await cloud_embedder.aclose()
if cloud_reranker is not None:
await cloud_reranker.aclose()
def main() -> None:
raise SystemExit(asyncio.run(async_main()))
if __name__ == "__main__":
main()

View File

@@ -1 +0,0 @@

102
backend/migrations/env.py Normal file
View File

@@ -0,0 +1,102 @@
from __future__ import annotations
import os
from logging.config import fileConfig
from pathlib import Path
from alembic import context
from sqlalchemy import create_engine
from sqlalchemy.engine import URL
from sqlalchemy.pool import NullPool
SCHEMA_NAME = "rag"
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = None
def _required_env(name: str, *, default: str | None = None) -> str:
value = os.getenv(name, default)
if value is None or not value.strip():
raise RuntimeError(f"{name} is required")
return value.strip()
def _database_password() -> str:
secret_path = os.getenv("POSTGRES_PASSWORD_FILE")
if secret_path:
password = Path(secret_path).read_text(encoding="utf-8").strip()
else:
password = os.getenv("POSTGRES_PASSWORD", "").strip()
if not password:
raise RuntimeError(
"POSTGRES_PASSWORD_FILE must point to a non-empty secret "
"(POSTGRES_PASSWORD is allowed only for explicit local tooling)"
)
return password
def _database_url() -> URL:
raw_port = _required_env("POSTGRES_PORT", default="5432")
try:
port = int(raw_port)
except ValueError as exc:
raise RuntimeError("POSTGRES_PORT must be an integer") from exc
return URL.create(
drivername="postgresql+psycopg",
username=_required_env("POSTGRES_USER"),
password=_database_password(),
host=_required_env("POSTGRES_HOST", default="db"),
port=port,
database=_required_env("POSTGRES_DB", default="geological_rag"),
)
def run_migrations_offline() -> None:
context.configure(
# Offline SQL generation needs only the dialect and must not materialize
# a real credential in generated output or process diagnostics.
url="postgresql+psycopg://",
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
include_schemas=True,
version_table_schema=SCHEMA_NAME,
transaction_per_migration=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = create_engine(
_database_url(),
poolclass=NullPool,
connect_args={"options": f"-csearch_path={SCHEMA_NAME},public"},
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
include_schemas=True,
version_table_schema=SCHEMA_NAME,
compare_type=True,
compare_server_default=True,
transaction_per_migration=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from collections.abc import Sequence
from alembic import op
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: str | None = ${repr(down_revision)}
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

File diff suppressed because it is too large Load Diff

58
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,58 @@
[project]
name = "geological-rag-backend"
version = "0.1.0"
description = "Backend and reproducible PoC tools for the geological prospecting RAG system"
readme = "README.md"
requires-python = ">=3.12,<3.13"
dependencies = [
"alembic>=1.14,<2",
"fastapi>=0.115,<1",
"httpx>=0.28,<1",
"pgvector>=0.3,<1",
"psycopg[binary]>=3.2,<4",
"pydantic>=2.10,<3",
"pydantic-settings>=2.7,<3",
"sqlalchemy>=2.0,<3",
"uvicorn[standard]>=0.34,<1",
]
[dependency-groups]
dev = [
"mypy>=1.14,<2",
"pytest>=8.3,<10",
"pytest-asyncio>=0.25,<2",
"ruff>=0.9,<1",
]
[build-system]
requires = ["hatchling>=1.27,<2"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app"]
[tool.pytest.ini_options]
addopts = "-ra --strict-config --strict-markers"
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "ASYNC", "S"]
ignore = ["S101"]
[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["S105", "S106", "S603", "S607"]
[tool.mypy]
python_version = "3.12"
strict = true
warn_unreachable = true
plugins = ["pydantic.mypy"]
[[tool.mypy.overrides]]
module = ["pgvector.*"]
ignore_missing_imports = true

View File

@@ -1 +0,0 @@

View File

@@ -0,0 +1,175 @@
from __future__ import annotations
import json
import httpx
import pytest
from app.adapters.bailian.chat import BailianChatAdapter
from app.ports.model_providers import ChatMessage, ModelProviderError
BASE_URL = "https://workspace.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"
API_KEY = "sk-test-chat-secret"
@pytest.mark.asyncio
async def test_chat_completion_disables_thinking_and_search() -> None:
messages = [ChatMessage(role="user", content="Give a grounded answer")]
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/compatible-mode/v1/chat/completions"
assert request.headers["Authorization"] == f"Bearer {API_KEY}"
payload = json.loads(request.content)
assert payload["model"] == "deepseek-v4-flash"
assert payload["messages"] == [{"role": "user", "content": "Give a grounded answer"}]
assert payload["enable_thinking"] is False
assert payload["enable_search"] is False
assert payload["stream"] is False
return httpx.Response(
200,
json={
"id": "req_chat_1",
"model": "deepseek-v4-flash",
"choices": [
{
"message": {"role": "assistant", "content": "grounded"},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 5,
"completion_tokens": 1,
"total_tokens": 6,
},
},
)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
adapter = BailianChatAdapter(
api_key=API_KEY,
base_url=BASE_URL,
http_client=client,
)
result = await adapter.complete(messages, max_tokens=32)
assert result.content == "grounded"
assert result.finish_reason == "stop"
assert result.usage.total_tokens == 6
@pytest.mark.asyncio
async def test_chat_stream_disables_thinking_and_search_and_parses_sse() -> None:
def handler(request: httpx.Request) -> httpx.Response:
payload = json.loads(request.content)
assert payload["enable_thinking"] is False
assert payload["enable_search"] is False
assert payload["stream"] is True
assert payload["stream_options"] == {"include_usage": True}
chunks = (
'data: {"id":"req_stream","model":"deepseek-v4-flash",'
'"choices":[{"delta":{"content":""},"finish_reason":null}]}\n\n'
'data: {"id":"req_stream","model":"deepseek-v4-flash",'
'"choices":[{"delta":{"content":""},"finish_reason":"stop"}]}\n\n'
'data: {"id":"req_stream","model":"deepseek-v4-flash",'
'"choices":[],"usage":{"prompt_tokens":4,"completion_tokens":2,'
'"total_tokens":6}}\n\n'
"data: [DONE]\n\n"
)
return httpx.Response(
200,
headers={"content-type": "text/event-stream"},
content=chunks.encode(),
)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
adapter = BailianChatAdapter(
api_key=API_KEY,
base_url=BASE_URL,
http_client=client,
)
events = [
event
async for event in adapter.stream(
[ChatMessage(role="user", content="question")],
max_tokens=32,
)
]
assert "".join(event.delta for event in events) == "矿体"
assert events[1].finish_reason == "stop"
assert events[2].usage.total_tokens == 6
@pytest.mark.asyncio
async def test_chat_error_does_not_expose_key_or_message_body() -> None:
sensitive_text = "CONFIDENTIAL_PROMPT_BODY"
def handler(_: httpx.Request) -> httpx.Response:
return httpx.Response(
401,
json={
"error": {
"code": API_KEY,
"message": f"{sensitive_text}: {API_KEY}",
}
},
)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
adapter = BailianChatAdapter(
api_key=API_KEY,
base_url=BASE_URL,
http_client=client,
)
with pytest.raises(ModelProviderError) as exc_info:
await adapter.complete(
[ChatMessage(role="user", content=sensitive_text)],
max_tokens=8,
)
rendered = f"{exc_info.value!s} {exc_info.value!r}"
assert API_KEY not in rendered
assert sensitive_text not in rendered
@pytest.mark.asyncio
async def test_chat_stream_honors_retry_after_before_any_token() -> None:
calls = 0
def handler(_: httpx.Request) -> httpx.Response:
nonlocal calls
calls += 1
if calls == 1:
return httpx.Response(
429,
headers={"Retry-After": "0"},
json={"error": {"code": "rate_limited"}},
)
return httpx.Response(
200,
headers={"content-type": "text/event-stream"},
content=(
'data: {"model":"deepseek-v4-flash",'
'"choices":[{"delta":{"content":"正常"},"finish_reason":"stop"}]}\n\n'
"data: [DONE]\n\n"
).encode(),
)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
adapter = BailianChatAdapter(
api_key=API_KEY,
base_url=BASE_URL,
http_client=client,
max_retries=1,
retry_base_seconds=0,
)
events = [
event
async for event in adapter.stream(
[ChatMessage(role="user", content="question")],
max_tokens=8,
)
]
assert "".join(event.delta for event in events) == "正常"
assert calls == 2

View File

@@ -0,0 +1,208 @@
from __future__ import annotations
import json
import httpx
import pytest
from app.adapters.bailian._base import response_model
from app.adapters.bailian.embedding import BailianEmbeddingAdapter
from app.ports.model_providers import ModelProviderError, ProviderErrorKind
BASE_URL = "https://workspace.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"
API_KEY = "sk-test-embedding-secret"
def _vector(marker: float = 1.0) -> list[float]:
return [marker, *([0.0] * 1_023)]
def test_embedding_rejects_non_bailian_endpoint_before_request() -> None:
with pytest.raises(ModelProviderError) as exc_info:
BailianEmbeddingAdapter(
api_key=API_KEY,
base_url="https://attacker.invalid/compatible-mode/v1",
)
assert exc_info.value.kind is ProviderErrorKind.INVALID_REQUEST
assert exc_info.value.provider_code == "invalid_base_url"
def test_response_model_falls_back_when_provider_echoes_sensitive_value() -> None:
assert (
response_model(
{"model": API_KEY},
"text-embedding-v4",
sensitive_values=(API_KEY,),
)
== "text-embedding-v4"
)
@pytest.mark.asyncio
async def test_embedding_uses_compatible_endpoint_and_restores_index_order() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/compatible-mode/v1/embeddings"
assert request.headers["Authorization"] == f"Bearer {API_KEY}"
payload = json.loads(request.content)
assert payload == {
"model": "text-embedding-v4",
"input": ["first", "second"],
"dimensions": 1_024,
"encoding_format": "float",
}
return httpx.Response(
200,
json={
"id": "req_embedding_1",
"model": "text-embedding-v4",
"data": [
{"index": 1, "embedding": _vector(2.0)},
{"index": 0, "embedding": _vector(1.0)},
],
"usage": {"prompt_tokens": 2, "total_tokens": 2},
},
)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
adapter = BailianEmbeddingAdapter(
api_key=API_KEY,
base_url=BASE_URL,
token_counter=len,
http_client=client,
)
result = await adapter.embed_documents(["first", "second"])
assert len(result.vectors) == 2
assert all(len(vector) == 1_024 for vector in result.vectors)
assert result.vectors[0][0] == 1.0
assert result.vectors[1][0] == 2.0
assert result.request_id == "req_embedding_1"
assert result.usage.input_tokens == 2
@pytest.mark.asyncio
async def test_embedding_enforces_10_8192_and_33000_token_boundaries() -> None:
calls = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal calls
calls += 1
payload = json.loads(request.content)
return httpx.Response(
200,
json={
"data": [
{"index": index, "embedding": _vector()}
for index, _ in enumerate(payload["input"])
]
},
)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
adapter = BailianEmbeddingAdapter(
api_key=API_KEY,
base_url=BASE_URL,
token_counter=len,
http_client=client,
)
await adapter.embed_documents(["x"] * 10)
await adapter.embed_documents(["x" * 8_192])
await adapter.embed_documents([*(["x" * 8_192] * 4), "x" * 232])
assert calls == 3
invalid_batches = (
["x"] * 11,
["x" * 8_193],
[*(["x" * 8_192] * 4), "x" * 233],
)
for texts in invalid_batches:
with pytest.raises(ModelProviderError) as exc_info:
await adapter.embed_documents(texts)
assert exc_info.value.kind is ProviderErrorKind.INVALID_REQUEST
assert calls == 3
@pytest.mark.asyncio
async def test_embedding_rejects_non_1024_dimensional_response() -> None:
def handler(_: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={"data": [{"index": 0, "embedding": [1.0] * 1_023}]},
)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
adapter = BailianEmbeddingAdapter(
api_key=API_KEY,
base_url=BASE_URL,
token_counter=len,
http_client=client,
)
with pytest.raises(ModelProviderError) as exc_info:
await adapter.embed_query("sensitive geological paragraph")
assert exc_info.value.kind is ProviderErrorKind.INVALID_RESPONSE
assert exc_info.value.provider_code == "invalid_embedding_dimensions"
@pytest.mark.asyncio
async def test_embedding_provider_error_does_not_expose_key_or_body() -> None:
sensitive_text = "CONFIDENTIAL_GEOLOGICAL_BODY"
def handler(_: httpx.Request) -> httpx.Response:
return httpx.Response(
400,
json={
"id": sensitive_text,
"error": {
"code": API_KEY,
"message": f"failed for {sensitive_text} using {API_KEY}",
},
},
)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
adapter = BailianEmbeddingAdapter(
api_key=API_KEY,
base_url=BASE_URL,
token_counter=len,
http_client=client,
)
with pytest.raises(ModelProviderError) as exc_info:
await adapter.embed_query(sensitive_text)
rendered = f"{exc_info.value!s} {exc_info.value!r}"
assert API_KEY not in rendered
assert sensitive_text not in rendered
assert exc_info.value.provider_code is None
assert exc_info.value.request_id is None
@pytest.mark.asyncio
async def test_embedding_retries_timeout_with_a_fixed_bound() -> None:
calls = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal calls
calls += 1
if calls == 1:
raise httpx.ReadTimeout("simulated timeout", request=request)
return httpx.Response(
200,
json={"data": [{"index": 0, "embedding": _vector()}]},
)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
adapter = BailianEmbeddingAdapter(
api_key=API_KEY,
base_url=BASE_URL,
token_counter=len,
http_client=client,
max_retries=1,
retry_base_seconds=0,
)
result = await adapter.embed_query("query")
assert len(result.vectors[0]) == 1_024
assert calls == 2

View File

@@ -0,0 +1,149 @@
from __future__ import annotations
import json
import httpx
import pytest
from app.adapters.bailian.rerank import BailianRerankerAdapter
from app.ports.model_providers import ModelProviderError, ProviderErrorKind
BASE_URL = "https://workspace.cn-beijing.maas.aliyuncs.com/compatible-api/v1"
API_KEY = "sk-test-rerank-secret"
@pytest.mark.asyncio
async def test_rerank_uses_separate_endpoint_and_maps_result_indices() -> None:
documents = ["copper evidence", "gold evidence", "irrelevant"]
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/compatible-api/v1/reranks"
assert request.headers["Authorization"] == f"Bearer {API_KEY}"
payload = json.loads(request.content)
assert payload == {
"model": "qwen3-rerank",
"query": "where is gold",
"documents": documents,
"top_n": 2,
"instruct": "rank geological evidence",
}
return httpx.Response(
200,
json={
"id": "req_rerank_1",
"model": "qwen3-rerank",
"results": [
{"index": 1, "relevance_score": 0.91},
{"index": 0, "relevance_score": 0.72},
],
"usage": {"input_tokens": 42, "total_tokens": 42},
},
)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
adapter = BailianRerankerAdapter(
api_key=API_KEY,
base_url=BASE_URL,
token_counter=len,
http_client=client,
)
result = await adapter.rerank(
"where is gold",
documents,
top_n=2,
instruct="rank geological evidence",
)
assert [item.index for item in result.items] == [1, 0]
assert [item.document for item in result.items] == [documents[1], documents[0]]
assert [item.relevance_score for item in result.items] == [0.91, 0.72]
assert result.request_id == "req_rerank_1"
@pytest.mark.asyncio
async def test_rerank_enforces_500_4000_and_repeated_query_120000_formula() -> None:
calls = 0
def handler(_: httpx.Request) -> httpx.Response:
nonlocal calls
calls += 1
return httpx.Response(
200,
json={"results": [{"index": 0, "relevance_score": 1.0}]},
)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
adapter = BailianRerankerAdapter(
api_key=API_KEY,
base_url=BASE_URL,
token_counter=len,
http_client=client,
)
await adapter.rerank("q" * 4_000, ["d" * 4_000], top_n=1)
exactly_120000 = ["d" * 40] * 500
await adapter.rerank("q" * 200, exactly_120000, top_n=1)
assert calls == 2
invalid_requests = (
("q" * 4_001, ["d"]),
("q", ["d" * 4_001]),
("q", ["d"] * 501),
("q" * 200, [*(["d" * 40] * 499), "d" * 41]),
)
for query, documents in invalid_requests:
with pytest.raises(ModelProviderError) as exc_info:
await adapter.rerank(query, documents, top_n=1)
assert exc_info.value.kind is ProviderErrorKind.INVALID_REQUEST
assert calls == 2
@pytest.mark.asyncio
async def test_rerank_rejects_invalid_provider_index() -> None:
def handler(_: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={"results": [{"index": 2, "relevance_score": 0.9}]},
)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
adapter = BailianRerankerAdapter(
api_key=API_KEY,
base_url=BASE_URL,
token_counter=len,
http_client=client,
)
with pytest.raises(ModelProviderError) as exc_info:
await adapter.rerank("query", ["only document"], top_n=1)
assert exc_info.value.kind is ProviderErrorKind.INVALID_RESPONSE
assert exc_info.value.provider_code == "invalid_rerank_index"
@pytest.mark.asyncio
async def test_rerank_retries_5xx_with_a_fixed_bound() -> None:
calls = 0
def handler(_: httpx.Request) -> httpx.Response:
nonlocal calls
calls += 1
if calls == 1:
return httpx.Response(503, json={"error": {"code": "temporarily_unavailable"}})
return httpx.Response(
200,
json={"results": [{"index": 0, "relevance_score": 0.9}]},
)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
adapter = BailianRerankerAdapter(
api_key=API_KEY,
base_url=BASE_URL,
token_counter=len,
http_client=client,
max_retries=1,
retry_base_seconds=0,
)
result = await adapter.rerank("query", ["document"], top_n=1)
assert result.items[0].index == 0
assert calls == 2

View File

@@ -1 +0,0 @@

View File

@@ -0,0 +1,183 @@
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
BACKEND = ROOT / "backend"
sys.path.insert(0, str(BACKEND))
from app.persistence.job_queue_sql import ( # noqa: E402
CLAIM_JOB_SQL,
COMPLETE_JOB_SQL,
FAIL_OR_RETRY_JOB_SQL,
HEARTBEAT_JOB_SQL,
REAP_EXPIRED_JOBS_SQL,
)
COMPOSE = (ROOT / "compose.yaml").read_text(encoding="utf-8")
BOOTSTRAP = (ROOT / "ops/postgres/init/10-bootstrap-rag.sh").read_text(encoding="utf-8")
MIGRATION = (BACKEND / "migrations/versions/0001_initial_schema.py").read_text(encoding="utf-8")
def _service_block(name: str) -> str:
pattern = re.compile(rf"(?ms)^ {re.escape(name)}:\n(.*?)(?=^ [a-zA-Z0-9_-]+:\n|\Z)")
match = pattern.search(COMPOSE)
assert match is not None, f"missing Compose service: {name}"
return match.group(1)
def test_compose_isolates_database_credentials_and_networks() -> None:
db = _service_block("db")
migrate = _service_block("migrate")
provider_smoke = _service_block("provider-smoke")
seed_demo = _service_block("seed-demo")
seed_demo_offline = _service_block("seed-demo-offline")
assert "postgres_bootstrap_password" in db
assert "postgres_migrator_password" in db
assert "postgres_app_password" in db
assert "postgres_migrator_password" in migrate
assert "postgres_bootstrap_password" not in migrate
assert "postgres_app_password" not in migrate
assert "bailian_api_key" in provider_smoke
assert "postgres_" not in provider_smoke
assert "postgres_app_password" in seed_demo
assert "postgres_bootstrap_password" not in seed_demo
assert "postgres_migrator_password" not in seed_demo
assert "bailian_api_key" in seed_demo
assert "./data/samples/public:/demo:ro" in seed_demo
assert "postgres_app_password" in seed_demo_offline
assert "bailian_api_key" not in seed_demo_offline
assert "DEMO_PROVIDER_MODE: fake" in seed_demo_offline
assert "./data/samples/public:/demo:ro" in seed_demo_offline
assert re.search(r"(?ms)^ data:\n.*?^ internal: true$", COMPOSE)
assert re.search(r"(?m)^ edge:$", COMPOSE)
assert re.search(r"(?m)^ egress:$", COMPOSE)
def test_database_health_requires_tcp_and_atomic_bootstrap_sentinel() -> None:
db = _service_block("db")
assert "pg_isready -h 127.0.0.1 -p 5432" in db
assert ".rag-bootstrap-complete" in db
assert "CREATE EXTENSION IF NOT EXISTS vector" in BOOTSTRAP
assert 'CREATE ROLE :"migrator_user"' in BOOTSTRAP
assert 'CREATE ROLE :"app_user"' in BOOTSTRAP
assert "NOSUPERUSER" in BOOTSTRAP
assert "CREATE SCHEMA rag AUTHORIZATION" in BOOTSTRAP
assert "ALTER DEFAULT PRIVILEGES" in BOOTSTRAP
assert BOOTSTRAP.index("COMMIT;") < BOOTSTRAP.index("mv -f")
assert "\\getenv migrator_password RAG_MIGRATOR_PASSWORD" in BOOTSTRAP
assert "--set=migrator_password" not in BOOTSTRAP
assert "--set=app_password" not in BOOTSTRAP
assert "set -x" not in BOOTSTRAP
assert "official postgres entrypoint resolves POSTGRES_PASSWORD_FILE" in BOOTSTRAP
assert 'bootstrap_password="${POSTGRES_PASSWORD:-}"' in BOOTSTRAP
def test_initial_migration_has_vector_and_activation_contracts() -> None:
normalized = " ".join(MIGRATION.lower().split())
assert "embedding vector(1024)" in normalized
assert "using hnsw (embedding vector_cosine_ops)" in normalized
assert "where searchable" in normalized
assert "chunks_searchable_requires_ready_approved_embedding" in normalized
assert "check (embedding_dimension = 1024)" in normalized
assert "active_version_id uuid" in normalized
assert "documents_active_version_fk" in normalized
assert "deferrable initially deferred" in normalized
assert "foreign key (id, active_version_id)" in normalized
assert "references rag.document_versions (document_id, id)" in normalized
assert "local_parsed_pending_cloud_review" in normalized
assert "cloud_approved" in normalized
assert "outbound_manifest_sha256" in normalized
assert "cloud_processing_allowed" not in normalized
assert "document_versions_cloud_approval_bound" in normalized
assert "chunks_approval_binding_fk" in normalized
assert "create table rag.outbound_manifest_items" in normalized
assert "chunks_manifest_item_binding_fk" in normalized
assert "outbound_manifest_items_immutable_after_approval" in normalized
assert "chunks_guard_approved_mutation" in normalized
assert "chunks_guard_ready_vector_update" in normalized
assert "new.access_scope_id is distinct from old.access_scope_id" in normalized
assert "new.document_version_id is distinct from old.document_version_id" in normalized
assert "cloud_text text not null" in normalized
assert "cloud_text_sha256 char(64) not null" in normalized
assert "embedding_text = embedding_prefix || cloud_text" in normalized
assert "sha256(convert_to(cloud_text, 'utf8'))" in normalized
assert "sha256(convert_to(embedding_text, 'utf8'))" in normalized
assert "approval_status = 'cloud_approved'" in normalized
assert "document_versions_revoke_cloud_approval" in normalized
assert "new.review_state is distinct from old.review_state" in normalized
assert "set searchable = false" in normalized
assert "approval_status = revoked_state" in normalized
assert "embedding = null" in normalized
assert "embedding_profile_hash = null" in normalized
assert "where document_version_id = old.id" in normalized
assert "chunks_enforce_active_search_projection" in normalized
assert "document.active_version_id = new.document_version_id" in normalized
assert "version.status = 'ready'" in normalized
assert "documents_enforce_activation" in normalized
def test_downgrade_drops_manifest_items_before_document_versions() -> None:
normalized = " ".join(MIGRATION.lower().split())
manifest_drop = normalized.index("drop table if exists rag.outbound_manifest_items")
version_drop = normalized.index("drop table if exists rag.document_versions")
assert manifest_drop < version_drop
def test_initial_migration_has_fenced_job_schema_and_indexes() -> None:
normalized = " ".join(MIGRATION.lower().split())
assert "create table rag.background_jobs" in normalized
assert "lease_owner text" in normalized
assert "lease_token uuid" in normalized
assert "lease_until timestamptz" in normalized
assert "attempt <= max_attempts" in normalized
assert "background_jobs_lease_consistent" in normalized
assert "unique (job_type, idempotency_key)" in normalized
assert "background_jobs_claim_queued" in normalized
assert "background_jobs_reap_expired" in normalized
def test_job_claim_and_terminal_updates_are_fenced() -> None:
claim = " ".join(CLAIM_JOB_SQL.lower().split())
heartbeat = " ".join(HEARTBEAT_JOB_SQL.lower().split())
complete = " ".join(COMPLETE_JOB_SQL.lower().split())
failure = " ".join(FAIL_OR_RETRY_JOB_SQL.lower().split())
assert "for update skip locked" in claim
assert "lease_token = gen_random_uuid()" in claim
assert "attempt = job.attempt + 1" in claim
for statement in (heartbeat, complete, failure):
assert "job.status = 'running'" in statement
assert "job.lease_owner = :worker_id" in statement
assert "job.lease_token = :lease_token" in statement
assert "returning" in statement
assert "when job.attempt < job.max_attempts then 'queued'" in failure
assert "else 'failed'" in failure
def test_reaper_uses_advisory_lock_and_handles_exhausted_attempts() -> None:
reaper = " ".join(REAP_EXPIRED_JOBS_SQL.lower().split())
assert "pg_try_advisory_xact_lock" in reaper
assert "job.status = 'running'" in reaper
assert "job.lease_until < now()" in reaper
assert "for update of job skip locked" in reaper
assert "when job.attempt < job.max_attempts then 'queued'" in reaper
assert "else 'failed'" in reaper
assert "lease_owner = null" in reaper
assert "lease_token = null" in reaper
assert "lease_until = null" in reaper

View File

@@ -1 +0,0 @@

View File

@@ -0,0 +1,74 @@
from pathlib import Path
import pytest
from app.core.config import Settings
from app.core.secrets import SecretFileError, read_secret_file
def test_secret_file_strips_single_trailing_newline(tmp_path: Path) -> None:
secret_path = tmp_path / "secret"
secret_path.write_text("test-only-value\n", encoding="utf-8")
assert read_secret_file(secret_path) == "test-only-value"
def test_secret_error_never_contains_secret_value(tmp_path: Path) -> None:
secret_path = tmp_path / "secret"
secret_path.write_text("first\nsecond\n", encoding="utf-8")
with pytest.raises(SecretFileError) as captured:
read_secret_file(secret_path)
assert "first" not in str(captured.value)
assert "second" not in str(captured.value)
def test_database_url_reads_password_from_file(tmp_path: Path) -> None:
password_path = tmp_path / "postgres_password"
password_path.write_text("local-test-password", encoding="utf-8")
settings = Settings(postgres_password_file=password_path)
url = settings.database_url()
assert url.password == "local-test-password"
assert "local-test-password" not in str(url)
def test_base_urls_drop_trailing_slashes() -> None:
settings = Settings(
bailian_openai_base_url="https://example.invalid/compatible-mode/v1/",
bailian_native_base_url="https://example.invalid/api/v1/",
bailian_rerank_base_url="https://example.invalid/compatible-api/v1/",
)
assert settings.bailian_openai_base_url.endswith("/v1")
assert settings.bailian_rerank_base_url.endswith("/v1")
def test_live_endpoints_must_share_one_beijing_workspace(tmp_path: Path) -> None:
key_path = tmp_path / "key"
key_path.write_text("test-key-value", encoding="utf-8")
settings = Settings(
dashscope_api_key_file=key_path,
bailian_openai_base_url=(
"https://workspace-a.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"
),
bailian_rerank_base_url=(
"https://workspace-b.cn-beijing.maas.aliyuncs.com/compatible-api/v1"
),
)
with pytest.raises(ValueError, match="same workspace"):
settings.bailian_api_key()
def test_live_endpoint_rejects_non_bailian_host_before_reading_key(tmp_path: Path) -> None:
settings = Settings(
dashscope_api_key_file=tmp_path / "missing",
bailian_openai_base_url="https://attacker.invalid/compatible-mode/v1",
bailian_rerank_base_url="https://attacker.invalid/compatible-api/v1",
)
with pytest.raises(ValueError, match="approved Beijing"):
settings.bailian_api_key()

View File

@@ -0,0 +1,37 @@
import json
from pathlib import Path
from typing import Any
PROJECT_ROOT = Path(__file__).resolve().parents[3]
SAMPLE_ROOT = PROJECT_ROOT / "data" / "samples" / "public"
def load_jsonl(path: Path) -> list[dict[str, Any]]:
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]
def test_demo_corpus_is_synthetic_pending_hash_bound_approval_and_unique() -> None:
documents = load_jsonl(SAMPLE_ROOT / "demo_documents.jsonl")
assert len(documents) == 20
assert len({document["doc_id"] for document in documents}) == 20
assert all(document["source_type"] == "synthetic" for document in documents)
assert all(
document["review_state"] == "LOCAL_PARSED_PENDING_CLOUD_REVIEW" for document in documents
)
assert all(document["cloud_policy_id"] == "synthetic-demo-v1" for document in documents)
assert all("cloud_approved" not in document for document in documents)
assert all(
"虚构" in document["content"] or "演示" in document["content"] for document in documents
)
def test_demo_queries_only_reference_existing_documents() -> None:
documents = load_jsonl(SAMPLE_ROOT / "demo_documents.jsonl")
queries = load_jsonl(SAMPLE_ROOT / "demo_queries.jsonl")
document_ids = {document["doc_id"] for document in documents}
assert len(queries) == 10
assert len({query["qid"] for query in queries}) == 10
assert all(set(query["expected_doc_ids"]) <= document_ids for query in queries)
assert any(not query["answerable"] and not query["expected_doc_ids"] for query in queries)

View File

@@ -0,0 +1,47 @@
import math
import pytest
from app.adapters.fake import FakeEmbeddingProvider, FakeReranker
from app.ports.model_providers import ModelProviderError
@pytest.mark.asyncio
async def test_fake_embedding_is_deterministic_normalized_and_1024_dimensional() -> None:
provider = FakeEmbeddingProvider()
first = await provider.embed_documents(["斑岩铜矿 钾化 绢英岩化"])
second = await provider.embed_query("斑岩铜矿 钾化 绢英岩化")
assert first.vectors[0] == second.vectors[0]
assert len(first.vectors[0]) == 1024
assert math.isclose(sum(value * value for value in first.vectors[0]), 1.0)
@pytest.mark.asyncio
async def test_fake_reranker_preserves_original_candidate_index() -> None:
reranker = FakeReranker()
documents = ["煤层测井对比", "斑岩铜矿钾化带", "铝土矿含矿层"]
result = await reranker.rerank("斑岩铜矿的钾化带", documents, top_n=2)
assert result.items[0].index == 1
assert result.items[0].document == documents[1]
assert result.items[0].relevance_score >= result.items[1].relevance_score
@pytest.mark.asyncio
async def test_fake_providers_reject_inputs_the_live_contract_rejects() -> None:
embedder = FakeEmbeddingProvider()
reranker = FakeReranker()
with pytest.raises(ModelProviderError):
await embedder.embed_documents([])
with pytest.raises(ModelProviderError):
await embedder.embed_documents(["x"] * 11)
with pytest.raises(ModelProviderError):
await reranker.rerank("", ["document"], top_n=1)
with pytest.raises(ModelProviderError):
await reranker.rerank("query", [], top_n=1)
with pytest.raises(ModelProviderError):
await reranker.rerank("query", ["document"], top_n=0)

View File

@@ -0,0 +1,30 @@
import httpx
import pytest
from app.main import app
@pytest.mark.asyncio
async def test_liveness_does_not_require_database_or_model_credentials() -> None:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/health/live")
assert response.status_code == 200
assert response.json()["status"] == "ok"
@pytest.mark.asyncio
async def test_meta_exposes_model_names_but_no_credentials() -> None:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/meta")
assert response.status_code == 200
payload = response.json()
assert payload["models"] == {
"embedding": "text-embedding-v4",
"rerank": "qwen3-rerank",
"generation": "deepseek-v4-flash",
}
assert "key" not in str(payload).lower()

View File

@@ -0,0 +1,50 @@
import json
from pathlib import Path
from typing import Any
import pytest
from app.adapters.fake import FakeEmbeddingProvider, FakeReranker
PROJECT_ROOT = Path(__file__).resolve().parents[3]
SAMPLE_ROOT = PROJECT_ROOT / "data" / "samples" / "public"
def load_jsonl(path: Path) -> list[dict[str, Any]]:
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]
def cosine(left: tuple[float, ...], right: tuple[float, ...]) -> float:
return sum(
left_value * right_value for left_value, right_value in zip(left, right, strict=True)
)
@pytest.mark.asyncio
async def test_synthetic_questions_retrieve_expected_document_after_rerank() -> None:
documents = load_jsonl(SAMPLE_ROOT / "demo_documents.jsonl")
queries = load_jsonl(SAMPLE_ROOT / "demo_queries.jsonl")
embedder = FakeEmbeddingProvider()
reranker = FakeReranker()
document_texts = [f"{item['title']}\n{item['content']}" for item in documents]
document_vectors: list[tuple[float, ...]] = []
for offset in range(0, len(document_texts), 10):
batch = await embedder.embed_documents(document_texts[offset : offset + 10])
document_vectors.extend(batch.vectors)
hits = 0
answerable_queries = [query for query in queries if query["answerable"]]
for query in answerable_queries:
query_vector = (await embedder.embed_query(query["query"])).vectors[0]
candidate_indexes = sorted(
range(len(documents)),
key=lambda index: cosine(query_vector, document_vectors[index]),
reverse=True,
)[:5]
candidate_texts = [document_texts[index] for index in candidate_indexes]
reranked = await reranker.rerank(query["query"], candidate_texts, top_n=3)
result_ids = [documents[candidate_indexes[item.index]]["doc_id"] for item in reranked.items]
if set(query["expected_doc_ids"]) & set(result_ids):
hits += 1
assert hits / len(answerable_queries) >= 0.8

View File

@@ -0,0 +1,26 @@
from app.ports.model_providers import ModelProviderError, ProviderErrorKind
from app.tools.provider_smoke import failed_probe
def test_failed_probe_only_exposes_sanitized_provider_fields() -> None:
error = ModelProviderError(
operation="embedding.create",
kind=ProviderErrorKind.AUTHENTICATION,
status_code=401,
provider_code="invalid_api_key",
request_id="req-safe-1",
)
result = failed_probe("embedding", error)
assert result.error_kind == "authentication"
assert result.status_code == 401
assert result.request_id == "req-safe-1"
assert "invalid_api_key" not in repr(result)
def test_unexpected_error_is_reduced_to_fixed_category() -> None:
result = failed_probe("chat", RuntimeError("sensitive content"))
assert result.error_kind == "internal_contract_error"
assert "sensitive content" not in repr(result)

View File

@@ -0,0 +1,38 @@
import subprocess
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[3]
SCANNER = PROJECT_ROOT / "scripts" / "check-secrets.sh"
def run_git(repository: Path, *arguments: str) -> None:
subprocess.run(
["git", *arguments],
cwd=repository,
check=True,
capture_output=True,
text=True,
)
def test_staged_scan_reads_index_blob_not_modified_worktree(tmp_path: Path) -> None:
run_git(tmp_path, "init", "--quiet")
run_git(tmp_path, "config", "user.email", "test@example.invalid")
run_git(tmp_path, "config", "user.name", "Secret Scanner Test")
candidate = tmp_path / "config.txt"
candidate.write_text("sk-" + "A" * 30, encoding="utf-8")
run_git(tmp_path, "add", "config.txt")
candidate.write_text("safe working-tree replacement", encoding="utf-8")
result = subprocess.run(
[str(SCANNER), "--staged"],
cwd=tmp_path,
check=False,
capture_output=True,
text=True,
)
assert result.returncode == 1
assert "config.txt" in result.stderr
assert "safe working-tree replacement" not in result.stderr
assert "A" * 30 not in result.stderr

View File

@@ -0,0 +1,54 @@
from pathlib import Path
import pytest
from app.adapters.fake import FakeEmbeddingProvider
from app.core.config import Settings
from app.tools.seed_demo import (
embed_in_batches,
embedding_profile_hash,
load_documents,
prepare_chunks,
)
PROJECT_ROOT = Path(__file__).resolve().parents[3]
DOCUMENTS_PATH = PROJECT_ROOT / "data" / "samples" / "public" / "demo_documents.jsonl"
@pytest.mark.asyncio
async def test_seed_preparation_batches_and_hash_binds_twenty_documents() -> None:
documents = load_documents(DOCUMENTS_PATH)
settings = Settings()
provider = FakeEmbeddingProvider()
texts = [
f"标题:{item.title}\n地区:{item.region}\n矿种:{item.mineral}\n正文:{item.content}"
for item in documents
]
vectors, model = await embed_in_batches(provider, texts)
chunks = prepare_chunks(
documents,
vectors,
profile_hash=embedding_profile_hash(settings, "fake"),
embedding_model=model,
)
assert len(chunks) == 20
assert all(len(item.vector) == 1024 for item in chunks)
assert all(item.embedding_text == item.embedding_prefix + item.cloud_text for item in chunks)
assert len({item.chunk_id for item in chunks}) == 20
assert all(len(item.outbound_manifest_sha256) == 64 for item in chunks)
def test_seed_rejects_fixture_not_explicitly_marked_synthetic(tmp_path: Path) -> None:
path = tmp_path / "documents.jsonl"
path.write_text(
'{"doc_id":"x","title":"x","content":"x","region":"x",'
'"mineral":"x","page_no":1,"source_type":"unknown",'
'"review_state":"LOCAL_PARSED_PENDING_CLOUD_REVIEW",'
'"cloud_policy_id":"synthetic-demo-v1"}\n',
encoding="utf-8",
)
with pytest.raises(ValueError, match="non_synthetic_document"):
load_documents(path)

680
backend/uv.lock generated Normal file
View File

@@ -0,0 +1,680 @@
version = 1
revision = 3
requires-python = "==3.12.*"
[[package]]
name = "alembic"
version = "1.18.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1a/cc/ac0bed8e562e7407fe55c3ba85a4dce86e6dbd8730887bd1e406a6c5c18a/alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e", size = 2060480, upload-time = "2026-06-25T15:20:54.888Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/78/5fe6dc3a3a5b2f5a2a4faef8bfe336d5fa049a38884ab3172e0098160c01/alembic-1.18.5-py3-none-any.whl", hash = "sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc", size = 264664, upload-time = "2026-06-25T15:20:56.673Z" },
]
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
]
[[package]]
name = "certifi"
version = "2026.6.17"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
]
[[package]]
name = "click"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "fastapi"
version = "0.139.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" },
]
[[package]]
name = "geological-rag-backend"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "alembic" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "pgvector" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "sqlalchemy" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.dev-dependencies]
dev = [
{ name = "mypy" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "alembic", specifier = ">=1.14,<2" },
{ name = "fastapi", specifier = ">=0.115,<1" },
{ name = "httpx", specifier = ">=0.28,<1" },
{ name = "pgvector", specifier = ">=0.3,<1" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4" },
{ name = "pydantic", specifier = ">=2.10,<3" },
{ name = "pydantic-settings", specifier = ">=2.7,<3" },
{ name = "sqlalchemy", specifier = ">=2.0,<3" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34,<1" },
]
[package.metadata.requires-dev]
dev = [
{ name = "mypy", specifier = ">=1.14,<2" },
{ name = "pytest", specifier = ">=8.3,<10" },
{ name = "pytest-asyncio", specifier = ">=0.25,<2" },
{ name = "ruff", specifier = ">=0.9,<1" },
]
[[package]]
name = "greenlet"
version = "3.5.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" },
{ url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" },
{ url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" },
{ url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" },
{ url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" },
{ url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" },
{ url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" },
{ url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" },
{ url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" },
{ url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" },
{ url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" },
{ url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" },
{ url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "librt"
version = "0.13.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" },
{ url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" },
{ url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" },
{ url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" },
{ url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" },
{ url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" },
{ url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" },
{ url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" },
{ url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" },
{ url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" },
{ url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" },
{ url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" },
{ url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" },
]
[[package]]
name = "mako"
version = "1.3.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
]
[[package]]
name = "mypy"
version = "1.20.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" },
{ url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" },
{ url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" },
{ url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" },
{ url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" },
{ url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" },
{ url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" },
{ url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pathspec"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
]
[[package]]
name = "pgvector"
version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a7/ec/6eb80aebc728200f95229219882994c1b0585b956ca47da5edb9d062627a/pgvector-0.5.0.tar.gz", hash = "sha256:07a9dcf735696879406983afc6eba9a787cef7c0cf6c367ca1a5779f036dee74", size = 35170, upload-time = "2026-07-06T18:27:27.767Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/e4/a5573f2c579ca9ad133293bfb624148ba0893674ca4a6eeec85ced9a6a09/pgvector-0.5.0-py3-none-any.whl", hash = "sha256:fedc9800894e6da2be51358d7b7c574bf34f247ca741a5a09513622135f5964f", size = 30958, upload-time = "2026-07-06T18:27:26.797Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "psycopg"
version = "3.3.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" },
]
[package.optional-dependencies]
binary = [
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
]
[[package]]
name = "psycopg-binary"
version = "3.3.4"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" },
{ url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" },
{ url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" },
{ url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" },
{ url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" },
{ url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" },
{ url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" },
{ url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" },
{ url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" },
{ url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" },
{ url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" },
]
[[package]]
name = "pydantic"
version = "2.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.14.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
]
[[package]]
name = "ruff"
version = "0.15.21"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" },
{ url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" },
{ url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" },
{ url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" },
{ url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" },
{ url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" },
{ url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" },
{ url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" },
{ url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" },
{ url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" },
{ url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" },
{ url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" },
{ url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" },
{ url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" },
{ url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" },
{ url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" },
{ url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.51"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" },
{ url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" },
{ url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" },
{ url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" },
{ url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" },
{ url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" },
{ url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" },
{ url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" },
]
[[package]]
name = "starlette"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "tzdata"
version = "2026.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" },
]
[[package]]
name = "uvicorn"
version = "0.51.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" },
]
[package.optional-dependencies]
standard = [
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
]
[[package]]
name = "watchfiles"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" },
{ url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" },
{ url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" },
{ url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" },
{ url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" },
{ url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" },
{ url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" },
{ url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" },
{ url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" },
{ url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" },
{ url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" },
{ url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" },
{ url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" },
{ url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" },
]
[[package]]
name = "websockets"
version = "16.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/02/b9a097e1e16fee4e2fd1ec8c39f6a9c5d6257bae8fa12640caf869f54436/websockets-16.1.tar.gz", hash = "sha256:299468cbe42e2b9981134c7c51d99387d8a7bf562b00183b3eec53f882846dad", size = 182530, upload-time = "2026-07-10T06:32:57.734Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/52/748c014f07f4e0e170c8932de7e647a1511d5ab3049cd978797136aee577/websockets-16.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b6aa3f7ad345cf3862c21f4fbf2ef5e14d911348476c2845e137c091fe3a3f0b", size = 179798, upload-time = "2026-07-10T06:31:09.664Z" },
{ url = "https://files.pythonhosted.org/packages/8b/5e/2a2e64d977d084e49d37c187c26c056daaff41965be7300cd5dbde6f8b07/websockets-16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b43fcfb521ac2f34ba80b7b8ea16303e4ad82dd8af667bf40839ad3a5d37b164", size = 177478, upload-time = "2026-07-10T06:31:11.072Z" },
{ url = "https://files.pythonhosted.org/packages/aa/12/5b85b4e75d697e548a94962ce5c036b05dd21cb9545759d555c5586422fc/websockets-16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2bd3e12cd9afbe2baedae0b1eeade8ba64329b60fe2f9abdc966bd10fd2c2ef5", size = 177746, upload-time = "2026-07-10T06:31:12.386Z" },
{ url = "https://files.pythonhosted.org/packages/9d/62/79b1c8f0cee0da648b4899e1c5b0dbd3aa59846985136a54854db6827ab4/websockets-16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f41979c8623df9bd30d949d82010a8fda5c56ff12cd8508a5b7272b6d4b53a", size = 187345, upload-time = "2026-07-10T06:31:13.754Z" },
{ url = "https://files.pythonhosted.org/packages/25/34/b7c5c52c2f24280e1c017acb7ad491a566750a5cceca7f3cf999373bba21/websockets-16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a24d1f35aef07d794a16c853c688e74956c50239bec37b4f2de080056046419b", size = 188581, upload-time = "2026-07-10T06:31:15.075Z" },
{ url = "https://files.pythonhosted.org/packages/bc/37/604193bebcbeffe96fdf795960b83a15d600880c64dc17ec9c31c5b3427d/websockets-16.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c64c024ddf7a35331b21fcddb562a039c275d2c82e8c2d12939e7da23997270", size = 191362, upload-time = "2026-07-10T06:31:16.395Z" },
{ url = "https://files.pythonhosted.org/packages/a5/b4/5ee27575b367d7110d4d13945e2a9de067ec84dc71e54b87f01e38550d9a/websockets-16.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e99757f5baafe20fc598e202ea6f5b0b265186ad38d0a17bd8beca16296955", size = 189216, upload-time = "2026-07-10T06:31:17.776Z" },
{ url = "https://files.pythonhosted.org/packages/7e/22/3e2dcc78d85fc5d9d814895ce6d07d0dfacc0f6aaa1d151f2b8c8d772299/websockets-16.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:353f3bc6e058ac1ccab4b3588e8598837a8c04cfc8351233e6d523be675d844c", size = 187971, upload-time = "2026-07-10T06:31:19.152Z" },
{ url = "https://files.pythonhosted.org/packages/9e/2f/cd271717b93d5ee19626cb5e38a85baab745c86e33db7c31a3ac729b31b8/websockets-16.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0352f5b38b40e857b6428d468fa21dbb4dd4a567d933c26d9831b4efe1b92f43", size = 185381, upload-time = "2026-07-10T06:31:20.665Z" },
{ url = "https://files.pythonhosted.org/packages/78/91/6ad6f2f1426317b5001bd490534208c7360636b35bac1dec2e0c22bfc40e/websockets-16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70bd789afab579602968c39f21cb925466505f3edff22f0ae852bca54978a4f9", size = 188015, upload-time = "2026-07-10T06:31:22.024Z" },
{ url = "https://files.pythonhosted.org/packages/c7/6d/533733132ab4c07540efd4a8f0b9a435d3a5059b2f26cc476ace1abf7f45/websockets-16.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d0fb4b46f121eccd539353baebd1083a8767a9a351109453d1d1caecd1ba40c2", size = 186619, upload-time = "2026-07-10T06:31:23.376Z" },
{ url = "https://files.pythonhosted.org/packages/08/73/16c059f3d73b3331eba10793704afa4faa9939234fb08ef7dca35794e8f0/websockets-16.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c14b6634af01541e4efe2954fd8f263386f7aa6d37c01e55dd8109fd17661452", size = 188497, upload-time = "2026-07-10T06:31:25.024Z" },
{ url = "https://files.pythonhosted.org/packages/4d/89/9a8fae7dd2acdcfb1a8844c29fe42b518a04b64fce38a0923b6290e452f1/websockets-16.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a58532c49a851bcb481e58c1be23b315c17fe2fbbed509d75aeea12f543d2c15", size = 186051, upload-time = "2026-07-10T06:31:26.291Z" },
{ url = "https://files.pythonhosted.org/packages/f6/40/b240c7dd6a0e0c59c1f68377cc3015263521080c327c15f5e753c1f6d378/websockets-16.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4e969170c3b08e1d8dabd990fef1fa702c4233aeaabec33f871806e444f6a0e4", size = 187029, upload-time = "2026-07-10T06:31:27.605Z" },
{ url = "https://files.pythonhosted.org/packages/50/35/524e3fac40e47d6fdcf6c4b2c95ef1bc8a97e01593c90eff86621df7b716/websockets-16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ff9b000064b88787ba9f7a3cb2af2b68a658ca5aad76458a46469e7124b678a0", size = 187308, upload-time = "2026-07-10T06:31:28.927Z" },
{ url = "https://files.pythonhosted.org/packages/00/13/56840cf62c8859af6ba22b9529da937332468c80f32b598753e8a66d3990/websockets-16.1-cp312-cp312-win32.whl", hash = "sha256:b9f5d83f80f4d7c4bba6d97f3755ac05850c784dce0fd2ab371c4e41172f53ff", size = 180161, upload-time = "2026-07-10T06:31:30.316Z" },
{ url = "https://files.pythonhosted.org/packages/d6/ff/87eb9eb44cb62424a8d729834f2b0515a47e2669fabec29820268f4d50a1/websockets-16.1-cp312-cp312-win_amd64.whl", hash = "sha256:6852c9f653966c16109d3b6f31181fd734f7914927e3f0fa1117af7a18c9aa21", size = 180462, upload-time = "2026-07-10T06:31:31.708Z" },
{ url = "https://files.pythonhosted.org/packages/66/58/bd83247f39ddc26ffc2c24eb05087a3b749e00cb4509fc6d19daa23c8495/websockets-16.1-py3-none-any.whl", hash = "sha256:c5149dfe490ec7e5ee5dbf624c642fb725f93a5575c7f00ab594ca9eddb8dd81", size = 174031, upload-time = "2026-07-10T06:32:56.079Z" },
]