Make the first RAG slice executable without risking production data
Some checks failed
verify / verify (push) Has been cancelled
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:
10
backend/.dockerignore
Normal file
10
backend/.dockerignore
Normal 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
33
backend/Dockerfile
Normal 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
46
backend/alembic.ini
Normal 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
3
backend/app/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Geological RAG backend package."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
11
backend/app/adapters/bailian/__init__.py
Normal file
11
backend/app/adapters/bailian/__init__.py
Normal 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",
|
||||
]
|
||||
382
backend/app/adapters/bailian/_base.py
Normal file
382
backend/app/adapters/bailian/_base.py
Normal 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()
|
||||
326
backend/app/adapters/bailian/chat.py
Normal file
326
backend/app/adapters/bailian/chat.py
Normal 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,
|
||||
)
|
||||
177
backend/app/adapters/bailian/embedding.py
Normal file
177
backend/app/adapters/bailian/embedding.py
Normal 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)
|
||||
208
backend/app/adapters/bailian/rerank.py
Normal file
208
backend/app/adapters/bailian/rerank.py
Normal 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)
|
||||
154
backend/app/adapters/fake.py
Normal file
154
backend/app/adapters/fake.py
Normal 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,
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
1
backend/app/core/__init__.py
Normal file
1
backend/app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Configuration and security primitives."""
|
||||
128
backend/app/core/config.py
Normal file
128
backend/app/core/config.py
Normal 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()
|
||||
24
backend/app/core/secrets.py
Normal file
24
backend/app/core/secrets.py
Normal 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
56
backend/app/main.py
Normal 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
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
17
backend/app/persistence/__init__.py
Normal file
17
backend/app/persistence/__init__.py
Normal 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",
|
||||
]
|
||||
129
backend/app/persistence/job_queue_sql.py
Normal file
129
backend/app/persistence/job_queue_sql.py
Normal 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.*
|
||||
"""
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
151
backend/app/ports/model_providers.py
Normal file
151
backend/app/ports/model_providers.py
Normal 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]: ...
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
1
backend/app/tools/__init__.py
Normal file
1
backend/app/tools/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Operational command entrypoints shipped in the backend image."""
|
||||
190
backend/app/tools/provider_smoke.py
Normal file
190
backend/app/tools/provider_smoke.py
Normal 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()
|
||||
666
backend/app/tools/seed_demo.py
Normal file
666
backend/app/tools/seed_demo.py
Normal 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()
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
102
backend/migrations/env.py
Normal file
102
backend/migrations/env.py
Normal 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()
|
||||
24
backend/migrations/script.py.mako
Normal file
24
backend/migrations/script.py.mako
Normal 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"}
|
||||
1015
backend/migrations/versions/0001_initial_schema.py
Normal file
1015
backend/migrations/versions/0001_initial_schema.py
Normal file
File diff suppressed because it is too large
Load Diff
58
backend/pyproject.toml
Normal file
58
backend/pyproject.toml
Normal 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
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
175
backend/tests/contract/test_bailian_chat.py
Normal file
175
backend/tests/contract/test_bailian_chat.py
Normal 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
|
||||
208
backend/tests/contract/test_bailian_embedding.py
Normal file
208
backend/tests/contract/test_bailian_embedding.py
Normal 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
|
||||
149
backend/tests/contract/test_bailian_rerank.py
Normal file
149
backend/tests/contract/test_bailian_rerank.py
Normal 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
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
183
backend/tests/integration/test_schema_contract.py
Normal file
183
backend/tests/integration/test_schema_contract.py
Normal 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
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
74
backend/tests/unit/test_config_and_secrets.py
Normal file
74
backend/tests/unit/test_config_and_secrets.py
Normal 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()
|
||||
37
backend/tests/unit/test_demo_data.py
Normal file
37
backend/tests/unit/test_demo_data.py
Normal 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)
|
||||
47
backend/tests/unit/test_fake_models.py
Normal file
47
backend/tests/unit/test_fake_models.py
Normal 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)
|
||||
30
backend/tests/unit/test_health_api.py
Normal file
30
backend/tests/unit/test_health_api.py
Normal 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()
|
||||
50
backend/tests/unit/test_offline_retrieval.py
Normal file
50
backend/tests/unit/test_offline_retrieval.py
Normal 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
|
||||
26
backend/tests/unit/test_provider_smoke.py
Normal file
26
backend/tests/unit/test_provider_smoke.py
Normal 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)
|
||||
38
backend/tests/unit/test_secret_scanner.py
Normal file
38
backend/tests/unit/test_secret_scanner.py
Normal 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
|
||||
54
backend/tests/unit/test_seed_demo.py
Normal file
54
backend/tests/unit/test_seed_demo.py
Normal 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
680
backend/uv.lock
generated
Normal 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" },
|
||||
]
|
||||
Reference in New Issue
Block a user