diff --git a/.gitea/workflows/secret-scan.yml b/.gitea/workflows/secret-scan.yml index 2c3dae6..af83c29 100644 --- a/.gitea/workflows/secret-scan.yml +++ b/.gitea/workflows/secret-scan.yml @@ -1,13 +1,19 @@ -name: secret-scan +name: verify on: push: pull_request: jobs: - scan: + verify: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Verify design and scan repository files - run: make verify-design + - uses: astral-sh/setup-uv@v6 + with: + version: "0.11.3" + enable-cache: true + - name: Install locked backend dependencies + run: make backend-sync + - name: Verify design, secrets, backend, and schema contracts + run: make verify-ci diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5cac0ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,69 @@ +# Secrets and local configuration +.env +.env.* +!.env.example +.envrc +secrets/ +*.pem +*.key +*.p12 +*.pfx + +# Python +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +coverage.xml +htmlcov/ +.venv/ +venv/ +.uv-cache/ + +# Node / frontend +node_modules/ +dist/ +build/ +coverage/ +.vite/ + +# Runtime data (manifests and public samples may be committed explicitly) +data/raw/ +data/interim/ +data/processed/ +data/*.json +data/*.jsonl +data/*.csv +data/*.parquet +data/uploads/ +data/indexes/ +data/evaluation/private/ +eval/ +uploads/ +backups/ +*.sqlite +*.sqlite3 + +# Logs and temporary files +logs/ +*.log +tmp/ +temp/ +.DS_Store +Thumbs.db + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Docker local overrides +compose.override.yaml +docker-compose.override.yml + +# Generated reports +runs/ +reports/generated/ diff --git a/Makefile b/Makefile index fff2d96..faf81c7 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,6 @@ -.PHONY: setup-hooks check-secrets docs-check links-check verify-design verify +.PHONY: setup-hooks check-secrets docs-check links-check verify-design backend-sync \ + backend-format backend-lint backend-type backend-test backend-check compose-check \ + verify-ci verify setup-hooks: git config core.hooksPath .githooks @@ -12,6 +14,8 @@ docs-check: test -f docs/01-data-and-evaluation.md test -f docs/02-deployment-and-security.md test -f docs/03-implementation-plan.md + test -f docs/04-project-todo.md + test -f docs/05-stage1-runbook.md links-check: python3 scripts/check_markdown_links.py @@ -20,6 +24,29 @@ verify-design: check-secrets docs-check links-check git diff --check git diff --cached --check -# During the design stage this aliases the design checks. Implementation stages -# must extend it with backend/frontend lint, types, tests, and image checks. -verify: verify-design +backend-sync: + cd backend && UV_CACHE_DIR=.uv-cache uv sync --all-groups --frozen + +backend-format: + backend/.venv/bin/ruff format --check backend/app backend/tests backend/migrations + +backend-lint: + backend/.venv/bin/ruff check backend/app backend/tests backend/migrations + +backend-type: + cd backend && .venv/bin/mypy app tests + +backend-test: + cd backend && .venv/bin/pytest + +backend-check: backend-sync backend-format backend-lint backend-type backend-test + +compose-check: + docker compose --env-file .env.example config --quiet + +# CI does not assume its runner exposes a Docker daemon. Schema and Compose +# security contracts still run in backend-test; local verification also asks the +# Docker CLI to render the real Compose model. +verify-ci: verify-design backend-check + +verify: verify-ci compose-check diff --git a/README.md b/README.md index c0cc4fc..2d1bf34 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 本仓库用于“基于 RAG 的地质找矿知识问答系统构建与应用”毕业设计。系统计划采用 Python/FastAPI 后端、React/TypeScript 前端、PostgreSQL + pgvector 向量存储,以及阿里云百炼的 `text-embedding-v4`、`qwen3-rerank`、`deepseek-v4-flash`。 -当前阶段为“设计与工程骨架”。完整方案、数据构建、模型接入、Docker 部署和评测方法见 [docs/README.md](docs/README.md)。在实现阶段开始前,仓库不会伪装成已经可运行的成品。 +当前处于 Stage 1“模型与数据库 PoC”:设计基线与离线 pgvector/适配器/seed 子闭环可运行,真实百炼 smoke 仍待轮换后的新 Key,React 产品功能尚未开始。完整方案、状态和评测方法见 [docs/README.md](docs/README.md),仓库不会把 PoC 伪装成已经完成的产品。 ## 目录 @@ -42,4 +42,19 @@ - [数据与评测设计](docs/01-data-and-evaluation.md) - [部署与安全设计](docs/02-deployment-and-security.md) - [实施计划与验收](docs/03-implementation-plan.md) +- [全生命周期 TODO 与进度](docs/04-project-todo.md) +- [Stage 1 PoC 运行手册](docs/05-stage1-runbook.md) - [架构决策记录](docs/adr/README.md) + +## 当前可运行入口 + +```bash +make setup-hooks +make backend-sync +bash scripts/init-local-secrets.sh +docker compose up -d --build db migrate +docker compose --profile tools run --rm seed-demo-offline +make verify +``` + +这组命令不需要百炼 Key,只使用 20 条虚构数据验证 pgvector 写入、检索、重排和幂等。真实模型运行必须先轮换聊天中已暴露的旧 Key,再按 [Stage 1 运行手册](docs/05-stage1-runbook.md) 操作。 diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..582b3ca --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,10 @@ +.venv +.uv-cache +__pycache__ +.pytest_cache +.mypy_cache +.ruff_cache +tests +*.pyc +*.pyo +.DS_Store diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..c3e017e --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..facbd02 --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..03bdf71 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1,3 @@ +"""Geological RAG backend package.""" + +__version__ = "0.1.0" diff --git a/backend/app/adapters/bailian/.gitkeep b/backend/app/adapters/bailian/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/backend/app/adapters/bailian/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/backend/app/adapters/bailian/__init__.py b/backend/app/adapters/bailian/__init__.py new file mode 100644 index 0000000..d7a0742 --- /dev/null +++ b/backend/app/adapters/bailian/__init__.py @@ -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", +] diff --git a/backend/app/adapters/bailian/_base.py b/backend/app/adapters/bailian/_base.py new file mode 100644 index 0000000..64d7008 --- /dev/null +++ b/backend/app/adapters/bailian/_base.py @@ -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() diff --git a/backend/app/adapters/bailian/chat.py b/backend/app/adapters/bailian/chat.py new file mode 100644 index 0000000..fae9975 --- /dev/null +++ b/backend/app/adapters/bailian/chat.py @@ -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, + ) diff --git a/backend/app/adapters/bailian/embedding.py b/backend/app/adapters/bailian/embedding.py new file mode 100644 index 0000000..b7d8f6c --- /dev/null +++ b/backend/app/adapters/bailian/embedding.py @@ -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) diff --git a/backend/app/adapters/bailian/rerank.py b/backend/app/adapters/bailian/rerank.py new file mode 100644 index 0000000..b519e16 --- /dev/null +++ b/backend/app/adapters/bailian/rerank.py @@ -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) diff --git a/backend/app/adapters/fake.py b/backend/app/adapters/fake.py new file mode 100644 index 0000000..812aa5a --- /dev/null +++ b/backend/app/adapters/fake.py @@ -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, + ) diff --git a/backend/app/core/.gitkeep b/backend/app/core/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/backend/app/core/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..f913407 --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1 @@ +"""Configuration and security primitives.""" diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..c9006f6 --- /dev/null +++ b/backend/app/core/config.py @@ -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://.cn-beijing.maas.aliyuncs.com/compatible-mode/v1" + ) + bailian_native_base_url: str = "https://.cn-beijing.maas.aliyuncs.com/api/v1" + bailian_rerank_base_url: str = ( + "https://.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() diff --git a/backend/app/core/secrets.py b/backend/app/core/secrets.py new file mode 100644 index 0000000..3b1127a --- /dev/null +++ b/backend/app/core/secrets.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..79b0d4f --- /dev/null +++ b/backend/app/main.py @@ -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 diff --git a/backend/app/persistence/.gitkeep b/backend/app/persistence/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/backend/app/persistence/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/backend/app/persistence/__init__.py b/backend/app/persistence/__init__.py new file mode 100644 index 0000000..28ea1b0 --- /dev/null +++ b/backend/app/persistence/__init__.py @@ -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", +] diff --git a/backend/app/persistence/job_queue_sql.py b/backend/app/persistence/job_queue_sql.py new file mode 100644 index 0000000..87bab9b --- /dev/null +++ b/backend/app/persistence/job_queue_sql.py @@ -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.* +""" diff --git a/backend/app/ports/.gitkeep b/backend/app/ports/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/backend/app/ports/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/backend/app/ports/model_providers.py b/backend/app/ports/model_providers.py new file mode 100644 index 0000000..32a991d --- /dev/null +++ b/backend/app/ports/model_providers.py @@ -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]: ... diff --git a/backend/app/tools/.gitkeep b/backend/app/tools/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/backend/app/tools/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/backend/app/tools/__init__.py b/backend/app/tools/__init__.py new file mode 100644 index 0000000..7afdc9a --- /dev/null +++ b/backend/app/tools/__init__.py @@ -0,0 +1 @@ +"""Operational command entrypoints shipped in the backend image.""" diff --git a/backend/app/tools/provider_smoke.py b/backend/app/tools/provider_smoke.py new file mode 100644 index 0000000..7ddc0f6 --- /dev/null +++ b/backend/app/tools/provider_smoke.py @@ -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( + "" 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() diff --git a/backend/app/tools/seed_demo.py b/backend/app/tools/seed_demo.py new file mode 100644 index 0000000..8b5a132 --- /dev/null +++ b/backend/app/tools/seed_demo.py @@ -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() diff --git a/backend/migrations/.gitkeep b/backend/migrations/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/backend/migrations/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/backend/migrations/env.py b/backend/migrations/env.py new file mode 100644 index 0000000..78a016a --- /dev/null +++ b/backend/migrations/env.py @@ -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() diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako new file mode 100644 index 0000000..b005ce4 --- /dev/null +++ b/backend/migrations/script.py.mako @@ -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"} diff --git a/backend/migrations/versions/0001_initial_schema.py b/backend/migrations/versions/0001_initial_schema.py new file mode 100644 index 0000000..029ef46 --- /dev/null +++ b/backend/migrations/versions/0001_initial_schema.py @@ -0,0 +1,1015 @@ +"""Create the initial RAG schema and vector index. + +Revision ID: 0001_initial_schema +Revises: None +Create Date: 2026-07-12 +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "0001_initial_schema" +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute( + """ + DO $migration$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_extension WHERE extname = 'vector' + ) THEN + RAISE EXCEPTION + 'pgvector extension is required; run the PostgreSQL bootstrap first'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_namespace WHERE nspname = 'rag' + ) THEN + RAISE EXCEPTION + 'rag schema is required; run the PostgreSQL bootstrap first'; + END IF; + END + $migration$; + """ + ) + + op.execute( + """ + CREATE TABLE rag.knowledge_bases ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + description text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT knowledge_bases_name_nonempty + CHECK (btrim(name) <> '') + ); + """ + ) + + op.execute( + """ + CREATE TABLE rag.access_scopes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + knowledge_base_id uuid NOT NULL, + name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT access_scopes_knowledge_base_fk + FOREIGN KEY (knowledge_base_id) + REFERENCES rag.knowledge_bases (id) + ON DELETE CASCADE, + CONSTRAINT access_scopes_name_nonempty + CHECK (btrim(name) <> ''), + CONSTRAINT access_scopes_knowledge_base_name_key + UNIQUE (knowledge_base_id, name), + CONSTRAINT access_scopes_knowledge_base_id_key + UNIQUE (knowledge_base_id, id) + ); + """ + ) + + op.execute( + """ + CREATE TABLE rag.documents ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + knowledge_base_id uuid NOT NULL, + access_scope_id uuid NOT NULL, + raw_sha256 char(64) NOT NULL, + filename text NOT NULL, + storage_key text NOT NULL, + mime_type text NOT NULL, + status text NOT NULL DEFAULT 'UPLOADED', + active_version_id uuid, + deleted_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT documents_knowledge_base_fk + FOREIGN KEY (knowledge_base_id) + REFERENCES rag.knowledge_bases (id) + ON DELETE CASCADE, + CONSTRAINT documents_access_scope_fk + FOREIGN KEY (knowledge_base_id, access_scope_id) + REFERENCES rag.access_scopes (knowledge_base_id, id) + ON DELETE RESTRICT, + CONSTRAINT documents_hash_format + CHECK (raw_sha256 ~ '^[0-9a-f]{64}$'), + CONSTRAINT documents_filename_nonempty + CHECK (btrim(filename) <> ''), + CONSTRAINT documents_storage_key_nonempty + CHECK (btrim(storage_key) <> ''), + CONSTRAINT documents_mime_type_nonempty + CHECK (btrim(mime_type) <> ''), + CONSTRAINT documents_status_valid + CHECK (status IN ( + 'UPLOADED', 'QUARANTINED_LOCAL_REVIEW', 'LOCAL_PARSING', + 'LOCAL_OCR_REQUIRED', 'LOCAL_OCR_RUNNING', 'LOCAL_NORMALIZING', + 'LOCAL_CHUNKING_AND_REDACTION', + 'LOCAL_PARSED_PENDING_CLOUD_REVIEW', 'CLOUD_APPROVED', + 'REJECTED', 'EMBEDDING', 'INDEXING', 'VALIDATING', + 'READY', 'FAILED', 'DELETED' + )), + CONSTRAINT documents_deleted_state_consistent + CHECK ( + (status = 'DELETED' AND deleted_at IS NOT NULL AND active_version_id IS NULL) + OR (status <> 'DELETED' AND deleted_at IS NULL) + ), + CONSTRAINT documents_ready_requires_active_version + CHECK (status <> 'READY' OR active_version_id IS NOT NULL), + CONSTRAINT documents_knowledge_base_hash_key + UNIQUE (knowledge_base_id, raw_sha256), + CONSTRAINT documents_knowledge_base_id_key + UNIQUE (knowledge_base_id, id) + ); + """ + ) + + op.execute( + """ + CREATE TABLE rag.document_versions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + document_id uuid NOT NULL, + parser_profile_hash char(64) NOT NULL, + ocr_profile_hash char(64), + normalization_profile_hash char(64) NOT NULL, + chunk_profile_hash char(64) NOT NULL, + cloud_policy_id text NOT NULL, + outbound_manifest_sha256 char(64), + review_state text NOT NULL DEFAULT 'QUARANTINED_LOCAL_REVIEW', + cloud_approved_at timestamptz, + cloud_approved_by text, + embedding_profile_hash char(64), + status text NOT NULL DEFAULT 'PENDING', + expected_chunk_count integer, + error_code text, + created_at timestamptz NOT NULL DEFAULT now(), + completed_at timestamptz, + CONSTRAINT document_versions_document_fk + FOREIGN KEY (document_id) + REFERENCES rag.documents (id) + ON DELETE CASCADE, + CONSTRAINT document_versions_parser_hash_format + CHECK (parser_profile_hash ~ '^[0-9a-f]{64}$'), + CONSTRAINT document_versions_ocr_hash_format + CHECK (ocr_profile_hash IS NULL OR ocr_profile_hash ~ '^[0-9a-f]{64}$'), + CONSTRAINT document_versions_normalization_hash_format + CHECK (normalization_profile_hash ~ '^[0-9a-f]{64}$'), + CONSTRAINT document_versions_chunk_hash_format + CHECK (chunk_profile_hash ~ '^[0-9a-f]{64}$'), + CONSTRAINT document_versions_embedding_hash_format + CHECK ( + embedding_profile_hash IS NULL + OR embedding_profile_hash ~ '^[0-9a-f]{64}$' + ), + CONSTRAINT document_versions_cloud_policy_nonempty + CHECK (btrim(cloud_policy_id) <> ''), + CONSTRAINT document_versions_manifest_hash_format + CHECK ( + outbound_manifest_sha256 IS NULL + OR outbound_manifest_sha256 ~ '^[0-9a-f]{64}$' + ), + CONSTRAINT document_versions_review_state_valid + CHECK (review_state IN ( + 'QUARANTINED_LOCAL_REVIEW', + 'LOCAL_PARSED_PENDING_CLOUD_REVIEW', + 'CLOUD_APPROVED', + 'REJECTED' + )), + CONSTRAINT document_versions_cloud_approval_bound + CHECK ( + ( + review_state = 'CLOUD_APPROVED' + AND outbound_manifest_sha256 IS NOT NULL + AND embedding_profile_hash IS NOT NULL + AND cloud_approved_at IS NOT NULL + AND cloud_approved_by IS NOT NULL + AND btrim(cloud_approved_by) <> '' + ) + OR ( + review_state <> 'CLOUD_APPROVED' + AND cloud_approved_at IS NULL + AND cloud_approved_by IS NULL + ) + ), + CONSTRAINT document_versions_status_valid + CHECK (status IN ('PENDING', 'PROCESSING', 'READY', 'FAILED')), + CONSTRAINT document_versions_chunk_count_valid + CHECK (expected_chunk_count IS NULL OR expected_chunk_count >= 0), + CONSTRAINT document_versions_completion_consistent + CHECK ( + (status IN ('READY', 'FAILED') AND completed_at IS NOT NULL) + OR (status IN ('PENDING', 'PROCESSING') AND completed_at IS NULL) + ), + CONSTRAINT document_versions_document_id_key + UNIQUE (document_id, id), + CONSTRAINT document_versions_approval_binding_key + UNIQUE ( + id, + review_state, + outbound_manifest_sha256, + embedding_profile_hash + ) + ); + """ + ) + + op.execute( + """ + ALTER TABLE rag.documents + ADD CONSTRAINT documents_active_version_fk + FOREIGN KEY (id, active_version_id) + REFERENCES rag.document_versions (document_id, id) + DEFERRABLE INITIALLY DEFERRED; + """ + ) + + op.execute( + """ + CREATE TABLE rag.outbound_manifest_items ( + document_version_id uuid NOT NULL, + ordinal integer NOT NULL, + outbound_manifest_sha256 char(64) NOT NULL, + cloud_text_sha256 char(64) NOT NULL, + embedding_text_sha256 char(64) NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT outbound_manifest_items_version_fk + FOREIGN KEY (document_version_id) + REFERENCES rag.document_versions (id) + ON DELETE CASCADE, + CONSTRAINT outbound_manifest_items_ordinal_valid + CHECK (ordinal >= 0), + CONSTRAINT outbound_manifest_items_hashes_valid + CHECK ( + outbound_manifest_sha256 ~ '^[0-9a-f]{64}$' + AND cloud_text_sha256 ~ '^[0-9a-f]{64}$' + AND embedding_text_sha256 ~ '^[0-9a-f]{64}$' + ), + CONSTRAINT outbound_manifest_items_primary_key + PRIMARY KEY (document_version_id, ordinal), + CONSTRAINT outbound_manifest_items_chunk_binding_key + UNIQUE ( + document_version_id, + ordinal, + outbound_manifest_sha256, + cloud_text_sha256, + embedding_text_sha256 + ) + ); + """ + ) + + op.execute( + """ + CREATE FUNCTION rag.guard_approved_manifest_item_mutation() + RETURNS trigger + LANGUAGE plpgsql + SECURITY INVOKER + SET search_path = pg_catalog, rag + AS $function$ + BEGIN + IF ( + TG_OP = 'INSERT' + AND EXISTS ( + SELECT 1 FROM rag.document_versions AS version + WHERE version.id = NEW.document_version_id + AND version.review_state = 'CLOUD_APPROVED' + ) + ) OR ( + TG_OP = 'DELETE' + AND EXISTS ( + SELECT 1 FROM rag.document_versions AS version + WHERE version.id = OLD.document_version_id + AND version.review_state = 'CLOUD_APPROVED' + ) + ) OR ( + TG_OP = 'UPDATE' + AND EXISTS ( + SELECT 1 FROM rag.document_versions AS version + WHERE version.id IN (OLD.document_version_id, NEW.document_version_id) + AND version.review_state = 'CLOUD_APPROVED' + ) + ) THEN + RAISE EXCEPTION + 'approved outbound manifest items are immutable; revoke approval first' + USING ERRCODE = '23514'; + END IF; + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; + END + $function$; + """ + ) + op.execute( + """ + CREATE TRIGGER outbound_manifest_items_immutable_after_approval + BEFORE INSERT OR UPDATE OR DELETE + ON rag.outbound_manifest_items + FOR EACH ROW + EXECUTE FUNCTION rag.guard_approved_manifest_item_mutation(); + """ + ) + + op.execute( + """ + CREATE TABLE rag.chunks ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + knowledge_base_id uuid NOT NULL, + document_id uuid NOT NULL, + document_version_id uuid NOT NULL, + access_scope_id uuid NOT NULL, + ordinal integer NOT NULL, + display_text text NOT NULL, + cloud_text text NOT NULL, + cloud_text_sha256 char(64) NOT NULL, + embedding_prefix text NOT NULL, + embedding_text text NOT NULL, + embedding_text_sha256 char(64) NOT NULL, + embedded_text_sha256 char(64), + embedding_profile_hash char(64), + outbound_manifest_sha256 char(64), + token_count integer NOT NULL, + page_start integer, + page_end integer, + section_path jsonb NOT NULL DEFAULT '[]'::jsonb, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + embedding_model text NOT NULL, + embedding_dimension smallint NOT NULL DEFAULT 1024, + embedding vector(1024), + approval_status text NOT NULL DEFAULT 'QUARANTINED_LOCAL_REVIEW', + index_status text NOT NULL DEFAULT 'PENDING', + searchable boolean NOT NULL DEFAULT false, + deleted_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT chunks_document_fk + FOREIGN KEY (knowledge_base_id, document_id) + REFERENCES rag.documents (knowledge_base_id, id) + ON DELETE CASCADE, + CONSTRAINT chunks_document_version_fk + FOREIGN KEY (document_id, document_version_id) + REFERENCES rag.document_versions (document_id, id) + ON DELETE CASCADE, + CONSTRAINT chunks_access_scope_fk + FOREIGN KEY (knowledge_base_id, access_scope_id) + REFERENCES rag.access_scopes (knowledge_base_id, id) + ON DELETE RESTRICT, + CONSTRAINT chunks_approval_binding_fk + FOREIGN KEY ( + document_version_id, + approval_status, + outbound_manifest_sha256, + embedding_profile_hash + ) + REFERENCES rag.document_versions ( + id, + review_state, + outbound_manifest_sha256, + embedding_profile_hash + ) + DEFERRABLE INITIALLY DEFERRED, + CONSTRAINT chunks_manifest_item_binding_fk + FOREIGN KEY ( + document_version_id, + ordinal, + outbound_manifest_sha256, + cloud_text_sha256, + embedding_text_sha256 + ) + REFERENCES rag.outbound_manifest_items ( + document_version_id, + ordinal, + outbound_manifest_sha256, + cloud_text_sha256, + embedding_text_sha256 + ) + DEFERRABLE INITIALLY DEFERRED, + CONSTRAINT chunks_ordinal_valid + CHECK (ordinal >= 0), + CONSTRAINT chunks_text_nonempty + CHECK ( + btrim(display_text) <> '' + AND btrim(cloud_text) <> '' + AND btrim(embedding_prefix) <> '' + AND btrim(embedding_text) <> '' + ), + CONSTRAINT chunks_cloud_text_hash_matches + CHECK ( + cloud_text_sha256 = encode( + sha256(convert_to(cloud_text, 'UTF8')), + 'hex' + ) + ), + CONSTRAINT chunks_embedding_input_bound_to_cloud_text + CHECK (embedding_text = embedding_prefix || cloud_text), + CONSTRAINT chunks_embedding_text_hash_matches + CHECK ( + embedding_text_sha256 = encode( + sha256(convert_to(embedding_text, 'UTF8')), + 'hex' + ) + ), + CONSTRAINT chunks_embedded_text_hash_format + CHECK ( + embedded_text_sha256 IS NULL + OR embedded_text_sha256 ~ '^[0-9a-f]{64}$' + ), + CONSTRAINT chunks_embedding_profile_hash_format + CHECK ( + embedding_profile_hash IS NULL + OR embedding_profile_hash ~ '^[0-9a-f]{64}$' + ), + CONSTRAINT chunks_manifest_hash_format + CHECK ( + outbound_manifest_sha256 IS NULL + OR outbound_manifest_sha256 ~ '^[0-9a-f]{64}$' + ), + CONSTRAINT chunks_token_count_valid + CHECK (token_count > 0), + CONSTRAINT chunks_pages_valid + CHECK ( + (page_start IS NULL AND page_end IS NULL) + OR ( + page_start IS NOT NULL + AND page_end IS NOT NULL + AND page_start > 0 + AND page_end >= page_start + ) + ), + CONSTRAINT chunks_section_path_array + CHECK (jsonb_typeof(section_path) = 'array'), + CONSTRAINT chunks_metadata_object + CHECK (jsonb_typeof(metadata) = 'object'), + CONSTRAINT chunks_embedding_model_nonempty + CHECK (btrim(embedding_model) <> ''), + CONSTRAINT chunks_embedding_dimension_1024 + CHECK (embedding_dimension = 1024), + CONSTRAINT chunks_vector_dimension_1024 + CHECK (embedding IS NULL OR vector_dims(embedding) = 1024), + CONSTRAINT chunks_approval_status_valid + CHECK (approval_status IN ( + 'QUARANTINED_LOCAL_REVIEW', + 'LOCAL_PARSED_PENDING_CLOUD_REVIEW', + 'CLOUD_APPROVED', + 'REJECTED' + )), + CONSTRAINT chunks_cloud_approval_has_complete_binding + CHECK ( + approval_status <> 'CLOUD_APPROVED' + OR ( + outbound_manifest_sha256 IS NOT NULL + AND embedding_profile_hash IS NOT NULL + ) + ), + CONSTRAINT chunks_index_status_valid + CHECK (index_status IN ('PENDING', 'EMBEDDING', 'READY', 'FAILED')), + CONSTRAINT chunks_ready_index_has_current_embedding + CHECK ( + index_status <> 'READY' + OR ( + embedding IS NOT NULL + AND embedded_text_sha256 IS NOT NULL + AND embedded_text_sha256 = embedding_text_sha256 + AND embedding_profile_hash IS NOT NULL + ) + ), + CONSTRAINT chunks_searchable_requires_ready_approved_embedding + CHECK ( + NOT searchable + OR ( + embedding IS NOT NULL + AND approval_status = 'CLOUD_APPROVED' + AND index_status = 'READY' + AND embedded_text_sha256 IS NOT NULL + AND embedded_text_sha256 = embedding_text_sha256 + AND embedding_profile_hash IS NOT NULL + AND outbound_manifest_sha256 IS NOT NULL + AND deleted_at IS NULL + ) + ), + CONSTRAINT chunks_document_version_ordinal_key + UNIQUE (document_version_id, ordinal) + ); + """ + ) + + op.execute( + """ + COMMENT ON COLUMN rag.chunks.display_text IS + 'Local-only normalized source text; never implied safe for cloud egress.'; + """ + ) + op.execute( + """ + COMMENT ON COLUMN rag.chunks.cloud_text IS + 'Redacted outbound text covered by the document-version approval manifest.'; + """ + ) + op.execute( + """ + COMMENT ON COLUMN rag.chunks.embedding_prefix IS + 'Provider document prefix selected by the approved embedding profile.'; + """ + ) + op.execute( + """ + COMMENT ON COLUMN rag.chunks.embedding_text IS + 'Exact provider input, database-enforced as embedding_prefix || cloud_text.'; + """ + ) + + op.execute( + """ + CREATE INDEX chunks_embedding_hnsw + ON rag.chunks + USING hnsw (embedding vector_cosine_ops) + WHERE searchable; + """ + ) + op.execute( + """ + CREATE INDEX chunks_access_filter + ON rag.chunks (knowledge_base_id, access_scope_id) + WHERE searchable; + """ + ) + op.execute( + """ + CREATE INDEX chunks_document_lookup + ON rag.chunks (document_id, document_version_id); + """ + ) + + op.execute( + """ + CREATE FUNCTION rag.guard_approved_chunk_mutation() + RETURNS trigger + LANGUAGE plpgsql + SECURITY INVOKER + SET search_path = pg_catalog, rag + AS $function$ + BEGIN + IF OLD.approval_status = 'CLOUD_APPROVED' + AND NEW.approval_status = 'CLOUD_APPROVED' + AND ( + NEW.knowledge_base_id IS DISTINCT FROM OLD.knowledge_base_id + OR NEW.document_id IS DISTINCT FROM OLD.document_id + OR NEW.document_version_id IS DISTINCT FROM OLD.document_version_id + OR NEW.access_scope_id IS DISTINCT FROM OLD.access_scope_id + OR NEW.ordinal IS DISTINCT FROM OLD.ordinal + OR NEW.cloud_text IS DISTINCT FROM OLD.cloud_text + OR NEW.cloud_text_sha256 IS DISTINCT FROM OLD.cloud_text_sha256 + OR NEW.embedding_prefix IS DISTINCT FROM OLD.embedding_prefix + OR NEW.embedding_text IS DISTINCT FROM OLD.embedding_text + OR NEW.embedding_text_sha256 IS DISTINCT FROM OLD.embedding_text_sha256 + OR NEW.outbound_manifest_sha256 + IS DISTINCT FROM OLD.outbound_manifest_sha256 + OR NEW.section_path IS DISTINCT FROM OLD.section_path + OR NEW.metadata IS DISTINCT FROM OLD.metadata + OR NEW.page_start IS DISTINCT FROM OLD.page_start + OR NEW.page_end IS DISTINCT FROM OLD.page_end + ) THEN + RAISE EXCEPTION + 'approved outbound chunk fields are immutable; revoke version approval first' + USING ERRCODE = '23514'; + END IF; + + IF OLD.index_status = 'READY' + AND NEW.index_status = 'READY' + AND ( + NEW.embedded_text_sha256 IS DISTINCT FROM OLD.embedded_text_sha256 + OR NEW.embedding_profile_hash IS DISTINCT FROM OLD.embedding_profile_hash + ) THEN + RAISE EXCEPTION + 'ready embedding is immutable; leave READY and searchable state before replacing it' + USING ERRCODE = '23514'; + END IF; + RETURN NEW; + END + $function$; + """ + ) + + op.execute( + """ + CREATE FUNCTION rag.guard_ready_embedding_update() + RETURNS trigger + LANGUAGE plpgsql + SECURITY INVOKER + SET search_path = pg_catalog, rag + AS $function$ + BEGIN + IF OLD.index_status = 'READY' AND NEW.index_status = 'READY' THEN + RAISE EXCEPTION + 'ready vector is immutable; set index_status to EMBEDDING before replacement' + USING ERRCODE = '23514'; + END IF; + RETURN NEW; + END + $function$; + """ + ) + op.execute( + """ + CREATE TRIGGER chunks_guard_ready_vector_update + BEFORE UPDATE OF embedding + ON rag.chunks + FOR EACH ROW + EXECUTE FUNCTION rag.guard_ready_embedding_update(); + """ + ) + op.execute( + """ + CREATE TRIGGER chunks_guard_approved_mutation + BEFORE UPDATE OF + knowledge_base_id, + document_id, + document_version_id, + access_scope_id, + ordinal, + cloud_text, + cloud_text_sha256, + embedding_prefix, + embedding_text, + embedding_text_sha256, + outbound_manifest_sha256, + section_path, + metadata, + page_start, + page_end, + embedding, + embedded_text_sha256, + embedding_profile_hash, + approval_status, + index_status + ON rag.chunks + FOR EACH ROW + EXECUTE FUNCTION rag.guard_approved_chunk_mutation(); + """ + ) + + op.execute( + """ + CREATE FUNCTION rag.enforce_chunk_searchability() + RETURNS trigger + LANGUAGE plpgsql + SECURITY INVOKER + SET search_path = pg_catalog, rag + AS $function$ + BEGIN + IF NEW.searchable AND NOT EXISTS ( + SELECT 1 + FROM rag.documents AS document + JOIN rag.document_versions AS version + ON version.id = NEW.document_version_id + AND version.document_id = document.id + WHERE document.id = NEW.document_id + AND document.knowledge_base_id = NEW.knowledge_base_id + AND document.active_version_id = NEW.document_version_id + AND document.status = 'READY' + AND document.deleted_at IS NULL + AND version.status = 'READY' + AND version.review_state = 'CLOUD_APPROVED' + AND version.outbound_manifest_sha256 = NEW.outbound_manifest_sha256 + AND version.embedding_profile_hash = NEW.embedding_profile_hash + ) THEN + RAISE EXCEPTION + 'searchable chunk must belong to the active READY approved version' + USING ERRCODE = '23514'; + END IF; + RETURN NEW; + END + $function$; + """ + ) + op.execute( + """ + CREATE TRIGGER chunks_enforce_active_search_projection + BEFORE INSERT OR UPDATE OF + searchable, + knowledge_base_id, + document_id, + document_version_id, + approval_status, + outbound_manifest_sha256, + embedding_profile_hash, + index_status + ON rag.chunks + FOR EACH ROW + EXECUTE FUNCTION rag.enforce_chunk_searchability(); + """ + ) + + op.execute( + """ + CREATE FUNCTION rag.enforce_document_activation() + RETURNS trigger + LANGUAGE plpgsql + SECURITY INVOKER + SET search_path = pg_catalog, rag + AS $function$ + BEGIN + IF NEW.active_version_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 + FROM rag.document_versions AS version + WHERE version.id = NEW.active_version_id + AND version.document_id = NEW.id + AND version.status = 'READY' + AND version.review_state = 'CLOUD_APPROVED' + ) THEN + RAISE EXCEPTION + 'active document version must be READY and CLOUD_APPROVED' + USING ERRCODE = '23514'; + END IF; + + IF NEW.active_version_id IS DISTINCT FROM OLD.active_version_id + OR NEW.status IS DISTINCT FROM OLD.status + OR NEW.deleted_at IS DISTINCT FROM OLD.deleted_at THEN + UPDATE rag.chunks + SET searchable = false, + updated_at = now() + WHERE document_id = OLD.id + AND ( + NEW.active_version_id IS NULL + OR document_version_id <> NEW.active_version_id + OR NEW.status <> 'READY' + OR NEW.deleted_at IS NOT NULL + ); + END IF; + RETURN NEW; + END + $function$; + """ + ) + op.execute( + """ + CREATE TRIGGER documents_enforce_activation + BEFORE UPDATE OF active_version_id, status, deleted_at + ON rag.documents + FOR EACH ROW + EXECUTE FUNCTION rag.enforce_document_activation(); + """ + ) + + op.execute( + """ + CREATE FUNCTION rag.revoke_cloud_approval_on_binding_change() + RETURNS trigger + LANGUAGE plpgsql + SECURITY INVOKER + SET search_path = pg_catalog, rag + AS $function$ + DECLARE + revoked_state text; + BEGIN + IF OLD.review_state = 'CLOUD_APPROVED' + AND ( + NEW.review_state IS DISTINCT FROM OLD.review_state + OR NEW.parser_profile_hash IS DISTINCT FROM OLD.parser_profile_hash + OR NEW.ocr_profile_hash IS DISTINCT FROM OLD.ocr_profile_hash + OR NEW.normalization_profile_hash + IS DISTINCT FROM OLD.normalization_profile_hash + OR NEW.chunk_profile_hash IS DISTINCT FROM OLD.chunk_profile_hash + OR NEW.cloud_policy_id IS DISTINCT FROM OLD.cloud_policy_id + OR NEW.embedding_profile_hash + IS DISTINCT FROM OLD.embedding_profile_hash + OR NEW.outbound_manifest_sha256 + IS DISTINCT FROM OLD.outbound_manifest_sha256 + ) THEN + revoked_state := CASE + WHEN NEW.review_state = 'CLOUD_APPROVED' + THEN 'LOCAL_PARSED_PENDING_CLOUD_REVIEW' + ELSE NEW.review_state + END; + + UPDATE rag.chunks + SET searchable = false, + approval_status = revoked_state, + outbound_manifest_sha256 = NULL, + index_status = 'PENDING', + embedding = NULL, + embedded_text_sha256 = NULL, + embedding_profile_hash = NULL, + updated_at = now() + WHERE document_version_id = OLD.id + AND approval_status = 'CLOUD_APPROVED'; + + UPDATE rag.documents + SET active_version_id = NULL, + status = CASE + WHEN revoked_state = 'REJECTED' THEN 'REJECTED' + ELSE 'LOCAL_PARSED_PENDING_CLOUD_REVIEW' + END, + updated_at = now() + WHERE active_version_id = OLD.id; + + NEW.review_state := revoked_state; + NEW.cloud_approved_at := NULL; + NEW.cloud_approved_by := NULL; + END IF; + RETURN NEW; + END + $function$; + """ + ) + + op.execute( + """ + CREATE FUNCTION rag.deactivate_chunks_when_version_not_ready() + RETURNS trigger + LANGUAGE plpgsql + SECURITY INVOKER + SET search_path = pg_catalog, rag + AS $function$ + BEGIN + IF OLD.status = 'READY' AND NEW.status <> 'READY' THEN + UPDATE rag.chunks + SET searchable = false, + index_status = CASE + WHEN NEW.status = 'FAILED' THEN 'FAILED' + ELSE index_status + END, + updated_at = now() + WHERE document_version_id = OLD.id; + + UPDATE rag.documents + SET active_version_id = NULL, + status = CASE + WHEN NEW.status = 'FAILED' THEN 'FAILED' + ELSE 'VALIDATING' + END, + updated_at = now() + WHERE active_version_id = OLD.id; + END IF; + RETURN NEW; + END + $function$; + """ + ) + op.execute( + """ + CREATE TRIGGER document_versions_deactivate_when_not_ready + BEFORE UPDATE OF status + ON rag.document_versions + FOR EACH ROW + EXECUTE FUNCTION rag.deactivate_chunks_when_version_not_ready(); + """ + ) + op.execute( + """ + CREATE TRIGGER document_versions_revoke_cloud_approval + BEFORE UPDATE OF + review_state, + parser_profile_hash, + ocr_profile_hash, + normalization_profile_hash, + chunk_profile_hash, + cloud_policy_id, + embedding_profile_hash, + outbound_manifest_sha256 + ON rag.document_versions + FOR EACH ROW + EXECUTE FUNCTION rag.revoke_cloud_approval_on_binding_change(); + """ + ) + + op.execute( + """ + CREATE TABLE rag.background_jobs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + job_type text NOT NULL, + required_capability text NOT NULL, + resource_type text NOT NULL, + resource_id uuid NOT NULL, + idempotency_key text NOT NULL, + payload jsonb NOT NULL DEFAULT '{}'::jsonb, + stage text NOT NULL DEFAULT 'PENDING', + status text NOT NULL DEFAULT 'QUEUED', + progress smallint NOT NULL DEFAULT 0, + priority integer NOT NULL DEFAULT 0, + attempt integer NOT NULL DEFAULT 0, + max_attempts integer NOT NULL DEFAULT 3, + run_after timestamptz NOT NULL DEFAULT now(), + lease_owner text, + lease_token uuid, + lease_until timestamptz, + last_error_code text, + last_error_message text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + finished_at timestamptz, + CONSTRAINT background_jobs_job_type_nonempty + CHECK (btrim(job_type) <> ''), + CONSTRAINT background_jobs_capability_nonempty + CHECK (btrim(required_capability) <> ''), + CONSTRAINT background_jobs_resource_type_nonempty + CHECK (btrim(resource_type) <> ''), + CONSTRAINT background_jobs_idempotency_key_nonempty + CHECK (btrim(idempotency_key) <> ''), + CONSTRAINT background_jobs_payload_object + CHECK (jsonb_typeof(payload) = 'object'), + CONSTRAINT background_jobs_stage_nonempty + CHECK (btrim(stage) <> ''), + CONSTRAINT background_jobs_status_valid + CHECK (status IN ('QUEUED', 'RUNNING', 'SUCCEEDED', 'FAILED', 'CANCELLED')), + CONSTRAINT background_jobs_progress_valid + CHECK (progress BETWEEN 0 AND 100), + CONSTRAINT background_jobs_attempts_valid + CHECK ( + attempt >= 0 + AND max_attempts > 0 + AND attempt <= max_attempts + ), + CONSTRAINT background_jobs_lease_consistent + CHECK ( + ( + status = 'RUNNING' + AND lease_owner IS NOT NULL + AND btrim(lease_owner) <> '' + AND lease_token IS NOT NULL + AND lease_until IS NOT NULL + ) + OR ( + status <> 'RUNNING' + AND lease_owner IS NULL + AND lease_token IS NULL + AND lease_until IS NULL + ) + ), + CONSTRAINT background_jobs_terminal_time_consistent + CHECK ( + ( + status IN ('SUCCEEDED', 'FAILED', 'CANCELLED') + AND finished_at IS NOT NULL + ) + OR ( + status IN ('QUEUED', 'RUNNING') + AND finished_at IS NULL + ) + ), + CONSTRAINT background_jobs_success_progress_complete + CHECK (status <> 'SUCCEEDED' OR progress = 100), + CONSTRAINT background_jobs_error_message_bounded + CHECK (last_error_message IS NULL OR length(last_error_message) <= 2000), + CONSTRAINT background_jobs_type_idempotency_key + UNIQUE (job_type, idempotency_key) + ); + """ + ) + op.execute( + """ + CREATE INDEX background_jobs_claim_queued + ON rag.background_jobs ( + required_capability, + priority DESC, + run_after, + created_at + ) + WHERE status = 'QUEUED'; + """ + ) + op.execute( + """ + CREATE INDEX background_jobs_reap_expired + ON rag.background_jobs (lease_until, required_capability) + WHERE status = 'RUNNING'; + """ + ) + op.execute( + """ + CREATE INDEX background_jobs_resource_lookup + ON rag.background_jobs (resource_type, resource_id, created_at DESC); + """ + ) + + +def downgrade() -> None: + op.execute( + """ + ALTER TABLE rag.documents + DROP CONSTRAINT IF EXISTS documents_active_version_fk; + """ + ) + op.execute("DROP TABLE IF EXISTS rag.background_jobs;") + op.execute("DROP TABLE IF EXISTS rag.chunks;") + op.execute("DROP FUNCTION IF EXISTS rag.guard_approved_chunk_mutation();") + op.execute("DROP FUNCTION IF EXISTS rag.guard_ready_embedding_update();") + op.execute("DROP FUNCTION IF EXISTS rag.enforce_chunk_searchability();") + op.execute("DROP TABLE IF EXISTS rag.outbound_manifest_items;") + op.execute("DROP FUNCTION IF EXISTS rag.guard_approved_manifest_item_mutation();") + op.execute("DROP TABLE IF EXISTS rag.document_versions;") + op.execute("DROP FUNCTION IF EXISTS rag.revoke_cloud_approval_on_binding_change();") + op.execute("DROP FUNCTION IF EXISTS rag.deactivate_chunks_when_version_not_ready();") + op.execute("DROP TABLE IF EXISTS rag.documents;") + op.execute("DROP FUNCTION IF EXISTS rag.enforce_document_activation();") + op.execute("DROP TABLE IF EXISTS rag.access_scopes;") + op.execute( + """ + DROP TABLE IF EXISTS rag.knowledge_bases; + """ + ) diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..caac511 --- /dev/null +++ b/backend/pyproject.toml @@ -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 diff --git a/backend/tests/contract/.gitkeep b/backend/tests/contract/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/backend/tests/contract/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/backend/tests/contract/test_bailian_chat.py b/backend/tests/contract/test_bailian_chat.py new file mode 100644 index 0000000..03b8410 --- /dev/null +++ b/backend/tests/contract/test_bailian_chat.py @@ -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 diff --git a/backend/tests/contract/test_bailian_embedding.py b/backend/tests/contract/test_bailian_embedding.py new file mode 100644 index 0000000..4e907a5 --- /dev/null +++ b/backend/tests/contract/test_bailian_embedding.py @@ -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 diff --git a/backend/tests/contract/test_bailian_rerank.py b/backend/tests/contract/test_bailian_rerank.py new file mode 100644 index 0000000..8be07c8 --- /dev/null +++ b/backend/tests/contract/test_bailian_rerank.py @@ -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 diff --git a/backend/tests/integration/.gitkeep b/backend/tests/integration/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/backend/tests/integration/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/backend/tests/integration/test_schema_contract.py b/backend/tests/integration/test_schema_contract.py new file mode 100644 index 0000000..2712632 --- /dev/null +++ b/backend/tests/integration/test_schema_contract.py @@ -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 diff --git a/backend/tests/unit/.gitkeep b/backend/tests/unit/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/backend/tests/unit/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/backend/tests/unit/test_config_and_secrets.py b/backend/tests/unit/test_config_and_secrets.py new file mode 100644 index 0000000..156a25b --- /dev/null +++ b/backend/tests/unit/test_config_and_secrets.py @@ -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() diff --git a/backend/tests/unit/test_demo_data.py b/backend/tests/unit/test_demo_data.py new file mode 100644 index 0000000..ced0ae5 --- /dev/null +++ b/backend/tests/unit/test_demo_data.py @@ -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) diff --git a/backend/tests/unit/test_fake_models.py b/backend/tests/unit/test_fake_models.py new file mode 100644 index 0000000..2f716a6 --- /dev/null +++ b/backend/tests/unit/test_fake_models.py @@ -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) diff --git a/backend/tests/unit/test_health_api.py b/backend/tests/unit/test_health_api.py new file mode 100644 index 0000000..8a9c63a --- /dev/null +++ b/backend/tests/unit/test_health_api.py @@ -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() diff --git a/backend/tests/unit/test_offline_retrieval.py b/backend/tests/unit/test_offline_retrieval.py new file mode 100644 index 0000000..2da83c6 --- /dev/null +++ b/backend/tests/unit/test_offline_retrieval.py @@ -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 diff --git a/backend/tests/unit/test_provider_smoke.py b/backend/tests/unit/test_provider_smoke.py new file mode 100644 index 0000000..4cdcda9 --- /dev/null +++ b/backend/tests/unit/test_provider_smoke.py @@ -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) diff --git a/backend/tests/unit/test_secret_scanner.py b/backend/tests/unit/test_secret_scanner.py new file mode 100644 index 0000000..b57b86c --- /dev/null +++ b/backend/tests/unit/test_secret_scanner.py @@ -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 diff --git a/backend/tests/unit/test_seed_demo.py b/backend/tests/unit/test_seed_demo.py new file mode 100644 index 0000000..1eb7ac3 --- /dev/null +++ b/backend/tests/unit/test_seed_demo.py @@ -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) diff --git a/backend/uv.lock b/backend/uv.lock new file mode 100644 index 0000000..e7e80a5 --- /dev/null +++ b/backend/uv.lock @@ -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" }, +] diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..2c29d07 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,160 @@ +name: geological-rag + +x-runtime-config: &runtime-config + APP_ENV: ${APP_ENV:-development} + APP_NAME: ${APP_NAME:-geological-rag} + POSTGRES_HOST: db + POSTGRES_PORT: "5432" + POSTGRES_DB: ${POSTGRES_DB:-geological_rag} + POSTGRES_USER: ${POSTGRES_APP_USER:-geological_rag_app} + POSTGRES_PASSWORD_FILE: /run/secrets/postgres_app_password + UPLOAD_ROOT: ${UPLOAD_ROOT:-/data/uploads} + MAX_UPLOAD_MB: "${MAX_UPLOAD_MB:-100}" + +x-rag-config: &rag-config + DASHSCOPE_API_KEY_FILE: /run/secrets/bailian_api_key + BAILIAN_OPENAI_BASE_URL: "${BAILIAN_OPENAI_BASE_URL:-}" + BAILIAN_NATIVE_BASE_URL: "${BAILIAN_NATIVE_BASE_URL:-}" + BAILIAN_RERANK_BASE_URL: "${BAILIAN_RERANK_BASE_URL:-}" + EMBEDDING_MODEL: ${EMBEDDING_MODEL:-text-embedding-v4} + EMBEDDING_DIMENSION: "${EMBEDDING_DIMENSION:-1024}" + RERANK_MODEL: ${RERANK_MODEL:-qwen3-rerank} + LLM_MODEL: ${LLM_MODEL:-deepseek-v4-flash} + CHUNK_TARGET_TOKENS: "${CHUNK_TARGET_TOKENS:-512}" + CHUNK_MAX_TOKENS: "${CHUNK_MAX_TOKENS:-800}" + CHUNK_OVERLAP_TOKENS: "${CHUNK_OVERLAP_TOKENS:-64}" + VECTOR_TOP_K: "${VECTOR_TOP_K:-50}" + RERANK_TOP_N: "${RERANK_TOP_N:-10}" + CONTEXT_TOP_N: "${CONTEXT_TOP_N:-8}" + MAX_CONTEXT_TOKENS: "${MAX_CONTEXT_TOKENS:-10000}" + MODEL_TIMEOUT_SECONDS: "${MODEL_TIMEOUT_SECONDS:-90}" + MODEL_MAX_RETRIES: "${MODEL_MAX_RETRIES:-3}" + MODEL_MAX_CONCURRENCY: "${MODEL_MAX_CONCURRENCY:-4}" + +services: + db: + image: pgvector/pgvector:0.8.2-pg17@sha256:feb68f4f15446397d8cac7f4fe48fe4586de83160d1fc48b46283312d1a33966 + environment: + POSTGRES_DB: ${POSTGRES_DB:-geological_rag} + POSTGRES_USER: ${POSTGRES_BOOTSTRAP_USER:-postgres} + POSTGRES_PASSWORD_FILE: /run/secrets/postgres_bootstrap_password + POSTGRES_MIGRATOR_USER: ${POSTGRES_MIGRATOR_USER:-geological_rag_migrator} + POSTGRES_MIGRATOR_PASSWORD_FILE: /run/secrets/postgres_migrator_password + POSTGRES_APP_USER: ${POSTGRES_APP_USER:-geological_rag_app} + POSTGRES_APP_PASSWORD_FILE: /run/secrets/postgres_app_password + secrets: + - postgres_bootstrap_password + - postgres_migrator_password + - postgres_app_password + volumes: + - postgres_data:/var/lib/postgresql/data + - ./ops/postgres/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: + - CMD-SHELL + - >- + pg_isready -h 127.0.0.1 -p 5432 -U $$POSTGRES_USER -d $$POSTGRES_DB + && test -f $$PGDATA/.rag-bootstrap-complete + interval: 5s + timeout: 3s + retries: 20 + networks: + - data + restart: unless-stopped + + migrate: + build: + context: ./backend + command: ["alembic", "upgrade", "head"] + depends_on: + db: + condition: service_healthy + environment: + POSTGRES_HOST: db + POSTGRES_PORT: "5432" + POSTGRES_DB: ${POSTGRES_DB:-geological_rag} + POSTGRES_USER: ${POSTGRES_MIGRATOR_USER:-geological_rag_migrator} + POSTGRES_PASSWORD_FILE: /run/secrets/postgres_migrator_password + secrets: + - postgres_migrator_password + networks: + - data + restart: "no" + + provider-smoke: + build: + context: ./backend + command: ["python", "-m", "app.tools.provider_smoke"] + profiles: ["tools"] + environment: *rag-config + secrets: + - bailian_api_key + networks: + - egress + restart: "no" + + seed-demo: + build: + context: ./backend + command: ["python", "-m", "app.tools.seed_demo"] + profiles: ["tools"] + depends_on: + migrate: + condition: service_completed_successfully + environment: + <<: [*runtime-config, *rag-config] + DEMO_PROVIDER_MODE: bailian + DEMO_DOCUMENTS_PATH: /demo/demo_documents.jsonl + DEMO_QUERIES_PATH: /demo/demo_queries.jsonl + secrets: + - postgres_app_password + - bailian_api_key + networks: + - data + - egress + volumes: + - ./data/samples/public:/demo:ro + restart: "no" + + seed-demo-offline: + build: + context: ./backend + command: ["python", "-m", "app.tools.seed_demo"] + profiles: ["tools"] + depends_on: + migrate: + condition: service_completed_successfully + environment: + <<: *runtime-config + DEMO_PROVIDER_MODE: fake + DEMO_DOCUMENTS_PATH: /demo/demo_documents.jsonl + DEMO_QUERIES_PATH: /demo/demo_queries.jsonl + secrets: + - postgres_app_password + networks: + - data + volumes: + - ./data/samples/public:/demo:ro + restart: "no" + +volumes: + postgres_data: + +networks: + edge: + driver: bridge + data: + driver: bridge + internal: true + egress: + driver: bridge + +secrets: + postgres_bootstrap_password: + file: ./secrets/postgres_bootstrap_password + postgres_migrator_password: + file: ./secrets/postgres_migrator_password + postgres_app_password: + file: ./secrets/postgres_app_password + bailian_api_key: + file: ./secrets/bailian_api_key diff --git a/data/samples/public/README.md b/data/samples/public/README.md new file mode 100644 index 0000000..a4ea234 --- /dev/null +++ b/data/samples/public/README.md @@ -0,0 +1,9 @@ +# Public synthetic PoC data + +本目录数据完全虚构,仅用于验证解析、向量化、入库、检索、重排和指标计算;不对应真实矿区、钻孔、机构或勘查成果。 + +- `demo_documents.jsonl`:20 条单切片级演示文档; +- `demo_queries.jsonl`:10 条问题、参考答案和期望文档 ID; +- 样例初始状态统一为 `LOCAL_PARSED_PENDING_CLOUD_REVIEW`;运行工具必须校验 `source_type=synthetic`,用 UUIDv5 生成数据库身份,实算文本/profile/manifest 哈希,再按固定 `synthetic-demo-v1` 策略完成审批绑定。文件中的普通字段不能直接越过云审批。 + +这些样例不能用于证明真实地质问答质量;正式评测必须使用取得授权、双人标注并隔离盲测的数据。 diff --git a/data/samples/public/demo_documents.jsonl b/data/samples/public/demo_documents.jsonl new file mode 100644 index 0000000..007af56 --- /dev/null +++ b/data/samples/public/demo_documents.jsonl @@ -0,0 +1,20 @@ +{"doc_id":"demo_geo_001","document_version_id":"demo_geo_001_v1","title":"青岚示范区斑岩铜矿蚀变分带","content":"青岚示范区的虚构剖面显示,中心钾化带以钾长石和黑云母为主,向外依次过渡为绢英岩化带和青磐岩化带。铜矿化在钾化带外缘与绢英岩化带内侧较强。","region":"青岚示范区","mineral":"铜","page_no":12,"source_type":"synthetic","review_state":"LOCAL_PARSED_PENDING_CLOUD_REVIEW","cloud_policy_id":"synthetic-demo-v1"} +{"doc_id":"demo_geo_002","document_version_id":"demo_geo_002_v1","title":"白石沟矽卡岩铁矿找矿标志","content":"白石沟为虚构矿区。含铁石榴子石—辉石矽卡岩、磁铁矿化及接触带磁异常共同构成优先验证标志,单独出现大理岩并不足以判断存在矿体。","region":"白石沟示范区","mineral":"铁","page_no":8,"source_type":"synthetic","review_state":"LOCAL_PARSED_PENDING_CLOUD_REVIEW","cloud_policy_id":"synthetic-demo-v1"} +{"doc_id":"demo_geo_003","document_version_id":"demo_geo_003_v1","title":"云岭浅成低温热液金矿特征","content":"云岭虚构样区的低硫化型矿化与石英—冰长石脉、梳状构造和低温蚀变有关。异常含金石英脉沿北东向张扭性断裂分布。","region":"云岭示范区","mineral":"金","page_no":21,"source_type":"synthetic","review_state":"LOCAL_PARSED_PENDING_CLOUD_REVIEW","cloud_policy_id":"synthetic-demo-v1"} +{"doc_id":"demo_geo_004","document_version_id":"demo_geo_004_v1","title":"赤河沉积改造型铅锌矿层位","content":"赤河虚构剖面中,铅锌矿化主要赋存于灰岩与碎屑岩过渡层位,受层间破碎带和后期白云石化叠加控制。找矿时应同时核对层位与构造,不能只追踪单一铅异常。","region":"赤河示范区","mineral":"铅锌","page_no":15,"source_type":"synthetic","review_state":"LOCAL_PARSED_PENDING_CLOUD_REVIEW","cloud_policy_id":"synthetic-demo-v1"} +{"doc_id":"demo_geo_005","document_version_id":"demo_geo_005_v1","title":"星谷伟晶岩型锂矿分带","content":"星谷虚构伟晶岩脉由边部细粒带、墙带、中间带和核部组成。含锂辉石的中间带是主要采样对象,风化表面仅见白色长石不能作为锂矿化的充分证据。","region":"星谷示范区","mineral":"锂","page_no":17,"source_type":"synthetic","review_state":"LOCAL_PARSED_PENDING_CLOUD_REVIEW","cloud_policy_id":"synthetic-demo-v1"} +{"doc_id":"demo_geo_006","document_version_id":"demo_geo_006_v1","title":"南丘铝土矿含矿层识别","content":"南丘虚构铝土矿位于不整合面之上的含铁黏土岩系中。矿石质量评价同时关注氧化铝、铝硅比和夹石厚度,不能用单个高铝样品代表整个层段。","region":"南丘示范区","mineral":"铝","page_no":6,"source_type":"synthetic","review_state":"LOCAL_PARSED_PENDING_CLOUD_REVIEW","cloud_policy_id":"synthetic-demo-v1"} +{"doc_id":"demo_geo_007","document_version_id":"demo_geo_007_v1","title":"玄峰岩浆硫化物镍矿判别","content":"玄峰虚构基性—超基性杂岩中,含矿性较好的部位位于岩体底部凹陷。浸染状磁黄铁矿、镍黄铁矿与铜镍比变化需结合岩相分带解释。","region":"玄峰示范区","mineral":"镍","page_no":29,"source_type":"synthetic","review_state":"LOCAL_PARSED_PENDING_CLOUD_REVIEW","cloud_policy_id":"synthetic-demo-v1"} +{"doc_id":"demo_geo_008","document_version_id":"demo_geo_008_v1","title":"松影石英脉型钨矿控矿因素","content":"松影虚构矿区的黑钨矿—石英脉受北西向裂隙组控制,云英岩化花岗岩位于脉群下盘。密集细脉带比孤立粗脉更适合作为连续工程验证目标。","region":"松影示范区","mineral":"钨","page_no":31,"source_type":"synthetic","review_state":"LOCAL_PARSED_PENDING_CLOUD_REVIEW","cloud_policy_id":"synthetic-demo-v1"} +{"doc_id":"demo_geo_009","document_version_id":"demo_geo_009_v1","title":"澄海碳酸岩型稀土矿勘查线索","content":"澄海为虚构样区。碳酸岩脉、霓石化和稀土元素组合异常可形成综合线索,但重砂中单颗稀土矿物不能独立证明原生矿体规模。","region":"澄海示范区","mineral":"稀土","page_no":11,"source_type":"synthetic","review_state":"LOCAL_PARSED_PENDING_CLOUD_REVIEW","cloud_policy_id":"synthetic-demo-v1"} +{"doc_id":"demo_geo_010","document_version_id":"demo_geo_010_v1","title":"北原煤层对比原则","content":"北原虚构煤田采用稳定标志层、煤层间距、测井曲线和古生物组合进行多证据对比。仅按煤层厚度相近进行跨孔对比容易误判。","region":"北原示范区","mineral":"煤","page_no":23,"source_type":"synthetic","review_state":"LOCAL_PARSED_PENDING_CLOUD_REVIEW","cloud_policy_id":"synthetic-demo-v1"} +{"doc_id":"demo_geo_011","document_version_id":"demo_geo_011_v1","title":"水系沉积物异常复查流程","content":"虚构区域化探中,先用重复样和标准物质排除分析漂移,再检查汇水域、粒级和人为污染。复查异常应沿上游逐级缩小范围,并与地质背景叠合。","region":"综合方法示范区","mineral":"多金属","page_no":4,"source_type":"synthetic","review_state":"LOCAL_PARSED_PENDING_CLOUD_REVIEW","cloud_policy_id":"synthetic-demo-v1"} +{"doc_id":"demo_geo_012","document_version_id":"demo_geo_012_v1","title":"激电异常解释约束","content":"激电高极化异常可能由浸染状硫化物、石墨或含水黏土引起。虚构案例要求联合电阻率、地质填图和露头检查后再布置钻孔,不能把高极化直接等同矿体。","region":"综合方法示范区","mineral":"硫化物","page_no":9,"source_type":"synthetic","review_state":"LOCAL_PARSED_PENDING_CLOUD_REVIEW","cloud_policy_id":"synthetic-demo-v1"} +{"doc_id":"demo_geo_013","document_version_id":"demo_geo_013_v1","title":"磁异常的地质解释","content":"虚构航磁剖面上的高磁异常既可能来自磁铁矿化,也可能来自未矿化的基性岩体。解释时需比较异常形态、剩磁、密度资料与地表岩性。","region":"综合方法示范区","mineral":"铁","page_no":14,"source_type":"synthetic","review_state":"LOCAL_PARSED_PENDING_CLOUD_REVIEW","cloud_policy_id":"synthetic-demo-v1"} +{"doc_id":"demo_geo_014","document_version_id":"demo_geo_014_v1","title":"钻孔岩芯编录最小字段","content":"演示钻孔编录至少记录回次、深度区间、岩性、蚀变、矿化、构造、岩芯采取率和照片编号。深度单位统一为米,缺失区间必须显式标注,禁止静默插值。","region":"工程验证示范区","mineral":"通用","page_no":3,"source_type":"synthetic","review_state":"LOCAL_PARSED_PENDING_CLOUD_REVIEW","cloud_policy_id":"synthetic-demo-v1"} +{"doc_id":"demo_geo_015","document_version_id":"demo_geo_015_v1","title":"化验质量控制样布置","content":"虚构化验批次每二十个常规样插入一个标准样、一个空白样和一个重复样。标准样超出控制限时应暂停发布该批结果并调查,而不是只删除异常控制样。","region":"工程验证示范区","mineral":"通用","page_no":5,"source_type":"synthetic","review_state":"LOCAL_PARSED_PENDING_CLOUD_REVIEW","cloud_policy_id":"synthetic-demo-v1"} +{"doc_id":"demo_geo_016","document_version_id":"demo_geo_016_v1","title":"遥感羟基蚀变异常核查","content":"虚构遥感处理得到的羟基异常需排除植被、云影和裸露黏土层影响。只有与构造、岩体接触带及地面蚀变露头吻合时,才提高外业核查优先级。","region":"综合方法示范区","mineral":"通用","page_no":18,"source_type":"synthetic","review_state":"LOCAL_PARSED_PENDING_CLOUD_REVIEW","cloud_policy_id":"synthetic-demo-v1"} +{"doc_id":"demo_geo_017","document_version_id":"demo_geo_017_v1","title":"断裂交汇部位的验证逻辑","content":"虚构构造模型认为两组断裂交汇可增加流体通道,但交汇本身不是矿化证据。优先靶区还需出现蚀变、地球化学或地球物理异常中的至少两类证据。","region":"构造示范区","mineral":"多金属","page_no":10,"source_type":"synthetic","review_state":"LOCAL_PARSED_PENDING_CLOUD_REVIEW","cloud_policy_id":"synthetic-demo-v1"} +{"doc_id":"demo_geo_018","document_version_id":"demo_geo_018_v1","title":"热液蚀变分带采样策略","content":"虚构热液系统从中心硅化向外过渡为绢云母化、泥化和青磐岩化。剖面采样应垂直分带方向并保持连续间距,避免只采颜色最鲜艳的部位。","region":"构造示范区","mineral":"多金属","page_no":16,"source_type":"synthetic","review_state":"LOCAL_PARSED_PENDING_CLOUD_REVIEW","cloud_policy_id":"synthetic-demo-v1"} +{"doc_id":"demo_geo_019","document_version_id":"demo_geo_019_v1","title":"资源量估算前的数据门禁","content":"虚构项目在资源量估算前检查坐标、标高、样长、密度、顶底板和质量控制结果。未经验证的历史孔只能作为找矿线索,不直接进入估算数据库。","region":"工程验证示范区","mineral":"通用","page_no":24,"source_type":"synthetic","review_state":"LOCAL_PARSED_PENDING_CLOUD_REVIEW","cloud_policy_id":"synthetic-demo-v1"} +{"doc_id":"demo_geo_020","document_version_id":"demo_geo_020_v1","title":"找矿问答的证据边界","content":"演示问答系统只能依据已批准语料作答。资料没有给出品位、厚度或坐标时应明确说明证据不足,不得根据相邻矿区或常见范围补造数值。","region":"系统评测示范区","mineral":"通用","page_no":1,"source_type":"synthetic","review_state":"LOCAL_PARSED_PENDING_CLOUD_REVIEW","cloud_policy_id":"synthetic-demo-v1"} diff --git a/data/samples/public/demo_queries.jsonl b/data/samples/public/demo_queries.jsonl new file mode 100644 index 0000000..d74ff4e --- /dev/null +++ b/data/samples/public/demo_queries.jsonl @@ -0,0 +1,10 @@ +{"qid":"demo_q_001","query":"青岚示范区斑岩铜矿从中心向外有哪些蚀变带?","expected_doc_ids":["demo_geo_001"],"reference_answer":"中心钾化带向外过渡为绢英岩化带和青磐岩化带。","answerable":true} +{"qid":"demo_q_002","query":"白石沟矽卡岩铁矿优先验证需要哪些综合标志?","expected_doc_ids":["demo_geo_002"],"reference_answer":"含铁石榴子石—辉石矽卡岩、磁铁矿化和接触带磁异常。","answerable":true} +{"qid":"demo_q_003","query":"星谷伟晶岩中哪个分带是含锂辉石的主要采样对象?","expected_doc_ids":["demo_geo_005"],"reference_answer":"中间带。","answerable":true} +{"qid":"demo_q_004","query":"为什么不能把激电高极化直接解释为矿体?","expected_doc_ids":["demo_geo_012"],"reference_answer":"高极化也可能由石墨或含水黏土等非矿因素引起,必须联合其他证据。","answerable":true} +{"qid":"demo_q_005","query":"演示钻孔岩芯编录至少记录哪些字段?","expected_doc_ids":["demo_geo_014"],"reference_answer":"回次、深度区间、岩性、蚀变、矿化、构造、岩芯采取率和照片编号。","answerable":true} +{"qid":"demo_q_006","query":"化验标准样超出控制限后应该怎么处理?","expected_doc_ids":["demo_geo_015"],"reference_answer":"暂停发布该批结果并开展调查。","answerable":true} +{"qid":"demo_q_007","query":"断裂交汇部位成为优先靶区还需要什么条件?","expected_doc_ids":["demo_geo_017"],"reference_answer":"还需至少两类蚀变、地球化学或地球物理证据。","answerable":true} +{"qid":"demo_q_008","query":"未经验证的历史钻孔能直接进入资源量估算数据库吗?","expected_doc_ids":["demo_geo_019"],"reference_answer":"不能,只能先作为找矿线索。","answerable":true} +{"qid":"demo_q_009","query":"资料没有提供品位数值时系统应该如何回答?","expected_doc_ids":["demo_geo_020"],"reference_answer":"明确说明证据不足,不得补造数值。","answerable":true} +{"qid":"demo_q_010","query":"青岚示范区某钻孔在200米处的金品位是多少?","expected_doc_ids":[],"reference_answer":"现有演示语料没有该信息,应拒绝推测。","answerable":false} diff --git a/docs/04-project-todo.md b/docs/04-project-todo.md new file mode 100644 index 0000000..eebc8cb --- /dev/null +++ b/docs/04-project-todo.md @@ -0,0 +1,560 @@ +# 项目全生命周期 TODO 与进度看板 + +| 项目 | 当前值 | +|---|---| +| 基线日期 | 2026-07-12 | +| 当前阶段 | Stage 1:模型与数据库 PoC,`IN_PROGRESS` | +| 已完成阶段 | Stage 0:仓库、安全和设计基线 | +| 整体完成度 | 约 12%,合理区间 10%–15% | +| 设计完成度 | 设计基线已完成;实现中发现新约束时继续通过 ADR 和文档修订维护 | +| 业务代码完成度 | 约 8%;Stage 1 的离线/数据库 PoC 已验收,真实百炼 smoke 仍待新 Key,前端产品功能尚未实现 | +| 当前预计剩余工期 | 约 9–13 周,含 300 题正式标注、盲测、论文和答辩缓冲 | +| 进度权威来源 | 本文的阶段状态、验收证据和已推送提交 | + +本文把 [总体设计](00-overall-design.md)、[数据与评测设计](01-data-and-evaluation.md)、[部署与安全设计](02-deployment-and-security.md) 和 [实施计划](03-implementation-plan.md) 转换为从当前状态到最终答辩、归档的可执行清单。它只表示计划和已验证事实,不因文档完整就声称系统代码已经完成。 + +## 1. 状态与完成度规则 + +### 1.1 状态定义 + +| 状态 | 含义 | 进入条件 | 退出条件 | +|---|---|---|---| +| `DONE` | 阶段已形成可验证闭环 | 功能、测试、文档、提交均完成 | 验收证据齐全且提交已推送 | +| `IN_PROGRESS` | 当前正在实施 | 依赖满足并已有明确执行任务 | 达到 `DONE`,或因硬门禁转为 `BLOCKED` | +| `TODO` | 尚未开始 | 前置阶段或资源未满足 | 依赖满足并正式启动 | +| `BLOCKED` | 硬门禁阻止继续 | 缺授权、密钥、外部服务或必要决策 | 阻断证据消除并记录恢复原因 | +| `OPTIONAL` | 不影响首版答辩主链路 | 核心目标已满足且仍有时间 | 完成或明确延期 | + +阶段只有在以下四项同时成立时才能标记 `DONE`: + +1. 范围内功能及失败路径已经实现。 +2. 适用的自动测试、静态检查和安全检查通过。 +3. 验收证据可复查,设计与运行文档已同步。 +4. 符合 Lore 协议的提交已经推送,远端提交可见。 + +### 1.2 进度口径 + +当前进度采用里程碑权重,不采用“文件数量”或“代码行数”: + +| 阶段 | 权重 | 当前状态 | 已计入进度 | +|---|---:|---|---:| +| Stage 0 设计与安全基线 | 12% | `DONE` | 12% | +| Stage 1 模型与数据库 PoC | 8% | `IN_PROGRESS` | 0% | +| Stage 2 完整工程骨架 | 10% | `TODO` | 0% | +| Stage 3 数字文档入库 | 14% | `TODO` | 0% | +| Stage 4 检索与重排 | 12% | `TODO` | 0% | +| Stage 5 生成、引用和 UI | 12% | `TODO` | 0% | +| Stage 6 OCR、表格与质量 | 10% | `TODO` | 0% | +| Stage 7 正式评测与消融 | 12% | `TODO` | 0% | +| Stage 8 安全、恢复、性能和论文 | 7% | `TODO` | 0% | +| Stage 9 答辩、发布与归档 | 3% | `TODO` | 0% | +| **合计** | **100%** | — | **12%** | + +Stage 1 已完成可离线验收的数据库、适配器契约和 synthetic seed 子闭环,阶段内部约完成 85%;但真实三模型 smoke 仍依赖轮换后的新 Key,因此 Stage 1 不提前计入整体里程碑。本文复选框表示“已通过验收”,不表示“文件是否已经出现”。当前可诚实表述为:**设计基线完成,整体里程碑约 12%,Stage 1 离线部分可运行,真实百炼验证尚未完成。** + +## 2. 阶段依赖与关键路径 + +```text +Stage 0 DONE + -> Stage 1 模型/数据库 PoC + -> Stage 2 完整工程骨架 + -> Stage 3 数字文档入库 + -> Stage 4 检索与重排 + -> Stage 6 OCR/表格(可与 Stage 4、5 部分并行) + -> Stage 5 生成/引用/UI + -> Stage 7 正式评测(标注可在 Stage 3 后提前启动) + -> Stage 8 安全/恢复/性能/论文 + -> Stage 9 答辩/发布/归档 +``` + +| 阶段 | 必须依赖 | 可提前并行的工作 | 不得提前做的工作 | +|---|---|---|---| +| Stage 1 | Stage 0、已轮换 Secret、受控联网 | 虚构数据和 fake server 契约测试 | 使用真实地质资料,或使用已暴露密钥 | +| Stage 2 | Stage 1 三模型和迁移 PoC | 前端静态布局、OpenAPI 生成脚本 | 绕过任务租约直接做不可恢复长任务 | +| Stage 3 | Stage 2 服务骨架 | 合法语料清单、标注规范准备 | 未通过 `CLOUD_APPROVED` 的内容出域 | +| Stage 4 | Stage 3 可追溯切片与向量 | 100 组三元组完善 | 用测试集调相似度、Top K 或阈值 | +| Stage 5 | Stage 4 稳定候选和引用 ID | UI 组件、Prompt fake 测试 | 让前端直接接触模型 Key | +| Stage 6 | Stage 3 数据状态机 | 与 Stage 4、5 并行开发 | 把图例 OCR 宣称为完整图件理解 | +| Stage 7 | Stage 4、5;正式总评需 Stage 6 | Stage 3 后开始问题编写和双审 | 开发者查看盲测答案,或把 Gold 入库 | +| Stage 8 | Stage 2–7 主链路稳定 | 论文各章随阶段持续写作 | 未恢复验证就声称备份可用 | +| Stage 9 | Stage 8 发布候选通过 | 演讲稿和演示素材提前准备 | 在最终演示前临时升级依赖或模型配置 | + +## 3. Stage 0:仓库、安全和设计基线 + +- **状态:** `DONE` +- **工期:** 已完成 +- **依赖:** 无 + +### 已完成任务 + +- [x] 建立仓库说明、贡献约定、安全说明和 AGENTS 约束。 +- [x] 建立 `backend/`、`frontend/`、`data/`、`ops/`、`scripts/` 和 `docs/` 结构。 +- [x] 完成 00–03 总体、数据评测、部署安全和实施计划设计基线。 +- [x] 建立 ADR 入口和首批关键架构决策。 +- [x] 建立 `.gitignore`、本地 Secret 扫描钩子和 Gitea 工作流基线。 +- [x] 建立 `make verify-design`、Markdown 相对链接和 diff 检查。 +- [x] 将 Stage 0 基线提交推送到 `origin/main`。 + +### 验收证据 + +- `git status --short --branch` 显示 `main...origin/main`,Stage 0 基线提交远端可见。 +- `make verify-design` 可检查 Secret、设计文档、相对链接和 diff。 +- [00-overall-design.md](00-overall-design.md) 明确状态为“设计基线,待实现验证”。 +- 当前不存在可运行应用,因此 Stage 0 的完成只代表设计和工程防线完成。 + +### 已完成提交/推送节点 + +- [x] `S0-A`:建立安全、架构、数据、部署和实施设计基线。 +- [x] 推送 `main` 并确认 `origin/main` 指向 Stage 0 基线提交。 + +### 后续仍持续生效的门禁 + +- 真实 Key、真实工作空间地址、受限数据和私有 benchmark 永不进入 Git。 +- 新实现若改变边界、数据状态或模型契约,必须先更新 ADR/设计再验收。 +- Stage 0 完成不能被用于宣称模型、数据库、后端或前端已经可运行。 + +## 4. Stage 1:模型与数据库 PoC + +- **状态:** `IN_PROGRESS` +- **预计工期:** 1–2 个工作日;外部密钥或网络未就绪时暂停计时 +- **依赖:** Stage 0;已轮换百炼 Key;允许的工作空间端点;本机 Docker + +### 具体实现任务 + +#### 4.1 安全与供应商前置 + +- [ ] 在百炼控制台撤销聊天中曾暴露的旧 Key,创建新 Key。 +- [ ] 通过 Docker Secret 或未提交的本地 Secret 文件注入新 Key;禁止写入 `.env.example`。 +- [ ] 分别确认 Embedding、Rerank 和 Chat 的实际路径、请求协议、模型返回名和能力指纹。 +- [ ] 记录脱敏的 endpoint identity hash、region、resolved revision 或受控 cache epoch。 +- [x] 建立 live provider 测试的手动开关;默认 CI 只使用 fake server。 + +#### 4.2 最小后端和 Compose + +- [x] 创建 `backend/pyproject.toml` 和依赖锁文件,不引入未说明依赖。 +- [x] 创建最小 `backend/Dockerfile`,使用非 root 用户和固定基础镜像 digest。 +- [x] 创建最小 `compose.yaml`,包含 `db + migrate + provider-smoke + seed-demo/offline`。 +- [x] 创建 PostgreSQL bootstrap、migrator 和 app 分权角色初始化脚本;备份只读角色在备份功能落地时单独创建。 +- [x] 创建 Alembic 基线迁移,启用 pgvector 并建立 1024 维向量表/HNSW 基线。 +- [x] 确保运行期服务不挂载 bootstrap 或 migrator Secret。 + +#### 4.3 三模型适配器 + +- [x] 实现 `EmbeddingProvider.embed_documents` 和 `embed_query` 契约。 +- [x] 通过 MockTransport 验证 `text-embedding-v4` 的 1024 维、批次下标和有限浮点数;真实响应待 smoke。 +- [ ] 对原生 Embedding 模式区分 `document/query`;兼容模式能力必须显式记录。 +- [x] 实现 `qwen3-rerank` 候选索引回填、Token 预检、Top 10 保存。 +- [x] 实现 `deepseek-v4-flash` 最小流式 Chat,默认关闭搜索和思考。 +- [x] 为三个适配器实现 timeout、429/5xx 有界重试和脱敏错误映射。 + +#### 4.4 虚构数据和测试 + +- [x] 创建 `provider_smoke`,只输出状态、模型、耗时、request ID 和脱敏错误码。 +- [x] 创建 `seed-demo`,使用 20 条明确标记的虚构文本完成写入、检索和重排。 +- [x] 重复运行 seed,证明行数和任务不增长。 +- [x] 建立 fake server 契约测试,覆盖成功、乱序、无效响应、401、429、5xx 和超时。 +- [x] 建立并执行迁移空 volume、角色权限和 Docker 重启持久化测试。 +- [x] 明确文档族 split、盲测保管人和“盲测用于调参即退役”规则。 +- [x] 扩展 `make verify`,纳入 Stage 1 后端格式、类型和测试门禁。 + +### 验收证据 + +- [x] `docker compose up db migrate` 在空 volume 成功建立扩展、角色和表。 +- [ ] `docker compose run --rm provider-smoke` 三模型探测成功,日志无 Secret。 +- [x] `docker compose run --rm seed-demo-offline` 完成 20 条虚构数据写入、检索和重排;真实模式待新 Key。 +- [x] 第二次运行 seed 后数据库计数与第一次相同,均为 chunks/vectors/searchable = 20/20/20。 +- [x] 41 项测试证明 Embedding 维度/下标、Rerank 下标、Chat 流式响应和失败路径正确。 +- [x] `make verify`、Secret 扫描、固定镜像构建和 `git diff --check` 通过。 + +### 2026-07-12 已验证运行证据 + +- 全新 volume 上 `db` 达到 healthy,`migrate` 以非超级用户退出码 0,版本为 `0001_initial_schema`。 +- `vector` 扩展与 HNSW 基线存在;app/migrator 均非超级用户,app 无 `rag` schema DDL 权限但具有所需 DML。 +- 两次离线 seed 均输出 20/20/20,9 个可回答虚构问题 Hit@3 = 9/9。 +- PostgreSQL 重启后计数仍为 20/20/20。 +- app 尝试修改已 `CLOUD_APPROVED` 的 `cloud_text` 被审批不可变触发器拒绝;尝试建表被权限拒绝。 +- 唯一未验收项是真实 `text-embedding-v4` / `qwen3-rerank` / `deepseek-v4-flash` smoke 与真实模式 seed。 + +### 提交/推送节点 + +- [ ] `S1-A`:数据库初始化、分权角色、迁移和最小 Compose;验证后提交并推送。 +- [ ] `S1-B`:三模型适配器、fake 契约测试和 live smoke;验证后提交并推送。 +- [ ] `S1-C`:20 条虚构 seed、幂等验证、运行证据和文档同步;验证后提交并推送。 + +建议最终阶段提交意图:`Prove the selected model chain before building product workflows`。 + +### 风险门禁 + +- **停止条件:** 新 Key 未轮换、Secret 出现在日志/镜像、真实端点能力不明、迁移使用超级用户运行。 +- **禁止数据:** Stage 1 只使用虚构文本,不上传任何真实地质报告。 +- **降级策略:** 供应商暂不可用时完成 fake 契约和数据库工作,但 Stage 1 不得标记 `DONE`。 +- **进入 Stage 2 的门槛:** 三模型真实 smoke、迁移和 seed 幂等全部通过。 + +## 5. Stage 2:扩展为完整工程骨架 + +- **状态:** `TODO` +- **预计工期:** 3–4 个工作日 +- **依赖:** Stage 1 `DONE` + +### 具体实现任务 + +- [ ] 建立 FastAPI 应用工厂、配置加载、稳定错误码和 OpenAPI。 +- [ ] 按 `domain -> ports -> services -> adapters/persistence` 落实后端依赖边界。 +- [ ] 建立 SQLAlchemy 仓储、Alembic 迁移运行器和事务边界。 +- [ ] 建立 PostgreSQL `background_jobs`、租约、lease token、fencing 和重试状态机。 +- [ ] 建立 Worker maintenance loop、advisory-lock reaper 和强杀恢复测试。 +- [ ] 建立 `/health/live`、`/health/ready` 和管理员模型能力探测端点。 +- [ ] 建立 React + TypeScript strict、路由、TanStack Query 和 OpenAPI 客户端生成。 +- [ ] 建立 Nginx,仅发布 Web 端口;API、Worker、DB 只在内部网络。 +- [ ] 建立完整 Compose:`web/api/worker/db/migrate`,OCR profile 默认关闭。 +- [ ] 建立 Ruff、类型、pytest、ESLint、TypeScript、Vitest 和镜像检查。 +- [ ] 建立 Gitea CI 的 fake provider 测试;外部 PR 不运行真实 Key 测试。 + +### 验收证据 + +- [ ] 空白机器仅按 README 执行即可启动完整骨架。 +- [ ] migration 只执行一次,失败时 API readiness 不通过。 +- [ ] `docker compose ps` 只暴露 Nginx/Web 端口。 +- [ ] Docker 重启后数据库数据保留。 +- [ ] 强杀 Worker 后验证可重领、耗尽进入 `FAILED`、旧 token 不能提交。 +- [ ] 后端/前端 lint、类型、单元和集成测试均通过。 + +### 提交/推送节点 + +- [ ] `S2-A`:FastAPI、持久化、健康端点和错误契约。 +- [ ] `S2-B`:任务租约、reaper、fencing 和恢复测试。 +- [ ] `S2-C`:React、Nginx、完整 Compose、CI 和文档。 +- [ ] 每个节点验证后独立提交并推送;Stage 2 完成时在本文更新状态。 + +### 风险门禁 + +- 不为单机毕设提前引入 Redis、Celery、微服务或独立向量数据库。 +- readiness 不把百炼短时故障等同于 API 容器不可用;模型健康单独展示。 +- 前端构建产物、OpenAPI 示例和测试日志不得包含 Key 或真实受限数据。 + +## 6. Stage 3:数字文档入库主链路 + +- **状态:** `TODO` +- **预计工期:** 1 周 +- **依赖:** Stage 2 `DONE`;合法数字资料样本和审核人可用 + +### 具体实现任务 + +- [ ] 实现上传流式大小、扩展名、MIME、页数、加密和恶意文件检查。 +- [ ] 实现 `QUARANTINED_LOCAL_REVIEW -> LOCAL_PARSED_PENDING_CLOUD_REVIEW -> CLOUD_APPROVED/REJECTED` 状态机。 +- [ ] 在隔离本地完成数字 PDF/DOCX/TXT/Markdown 解析、页码和章节恢复。 +- [ ] 生成 `raw_text`、脱敏 `cloud_text` 和 outbound manifest;所有外发调用前核对哈希。 +- [ ] 实现 512 Token 基线切分、64 Token 重叠、表头上下文和元数据前缀开关。 +- [ ] 实现精确去重、近重复族、版本关系和确定性 chunk ID。 +- [ ] 实现 Embedding 批次、内容缓存、chunk assignment、检查点和有界重试。 +- [ ] 实现 `documents.active_version_id` 与 `chunks.searchable` 单事务切换和持续对账。 +- [ ] 实现删除立即退出检索、后台物理清理和最小审计记录。 +- [ ] 实现知识库、文档列表、上传进度、复核状态和错误详情 UI。 +- [ ] 冻结 corpus manifest 与文档族 60/20/20 split;盲测标签由独立保管人控制。 +- [ ] 用允许使用的小规模数字 PDF 语料完成端到端入库演练。 + +### 验收证据 + +- [ ] 100% `searchable` 切片可追溯当前文档版本、页码和 outbound manifest。 +- [ ] `approved chunks == READY vector assignments`,维度和文本/profile 哈希一致。 +- [ ] 同一文件重复上传、任务重跑和中断恢复不产生重复记录或重复费用。 +- [ ] 新版本未完整 READY 前旧版本继续可检索;切换没有空窗。 +- [ ] 删除后在线查询立即不可命中,物理清理可审计。 +- [ ] 未获云审批的文档不会触发 Embedding、Rerank 或 LLM 请求。 + +### 提交/推送节点 + +- [ ] `S3-A`:上传、数据治理状态机和本地解析。 +- [ ] `S3-B`:结构切分、去重、向量任务和版本激活。 +- [ ] `S3-C`:文档 UI、删除/恢复测试、corpus manifest 和证据。 +- [ ] 每个节点通过安全与回归门禁后提交、推送。 + +### 风险门禁 + +- 权利、涉密等级、云处理权限任一不明确即停止外发。 +- `cloud_text`、处理器或 manifest 任一变化自动撤销原审批。 +- 解析失败页、跨页表和低置信内容必须显式失败或进入复核,不静默丢弃。 + +## 7. Stage 4:检索与重排 + +- **状态:** `TODO` +- **预计工期:** 1 周 +- **依赖:** Stage 3 `DONE`;开发集 qrels/100 组三元组可用 + +### 具体实现任务 + +- [ ] 实现 query embedding,并在原生模式使用 `text_type=query`。 +- [ ] 实现同权限过滤条件下的 exact Cosine Top K 基线。 +- [ ] 实现 HNSW、iterative scan、`ef_search`、over-fetch 和 ANN 对账。 +- [ ] 在候选生成 SQL 内应用知识库、角色、文档状态和可见范围过滤。 +- [ ] 实现 Dense Top 50、`qwen3-rerank` Top 10、生成上下文 Top 8。 +- [ ] 实现 Rerank 总 Token 预检、响应 index 回填、裁剪和错误分类。 +- [ ] 实现候选去重、邻接补全和上下文 Token 预算。 +- [ ] 实现检索实验室:展示查询配置、初召回、重排、来源和耗时。 +- [ ] 完善 100 组三元组,覆盖同义词、实体冲突、数字、单位、OCR 和难负例。 +- [ ] 校准 Top K 和阈值;所有参数只使用开发集。 + +### 验收证据 + +- [ ] 三元组 `sim(query, positive) > sim(query, hard_negative)` 达到预设目标或形成完整错误报告。 +- [ ] 至少 200 个查询的 ANN Recall@10 相对 exact 基线达到目标,过滤条件完全相同。 +- [ ] 未授权文档从候选生成阶段即被排除。 +- [ ] Rerank 前后 MRR/nDCG、延迟和 Token 可复查。 +- [ ] 相同运行配置可由 `config.yaml` 和 provider profile 重现。 + +### 提交/推送节点 + +- [ ] `S4-A`:exact/HNSW、权限过滤和 ANN 质量测试。 +- [ ] `S4-B`:Rerank、上下文构建和错误处理。 +- [ ] `S4-C`:检索实验室、三元组报告和参数冻结。 + +### 风险门禁 + +- 不使用一个固定余弦或 Rerank 分数跨所有问题通用。 +- Rerank 不能恢复初召回未命中的证据,错误归因必须分层。 +- 不得查看盲测答案后调整维度、Top K、阈值或 instruct。 + +## 8. Stage 5:生成、引用和问答 UI + +- **状态:** `TODO` +- **预计工期:** 1 周 +- **依赖:** Stage 4 `DONE` + +### 具体实现任务 + +- [ ] 建立版本化 Grounded Answer Prompt,要求仅依据编号证据回答。 +- [ ] 实现 `deepseek-v4-flash` SSE、取消传播、超时和流式错误事件。 +- [ ] 后端将模型引用编号映射到真实 chunk/doc/page,拒绝模型自造引用。 +- [ ] 实现证据不足拒答、模型失败时保留检索结果、资料冲突分来源陈述。 +- [ ] 实现会话与消息持久化、来源卡片、页码定位和用户反馈。 +- [ ] 实现文档管理、聊天、检索实验室、评测和系统状态页面导航。 +- [ ] 清理 Markdown/HTML,验证 XSS、危险链接和 Prompt Injection 回归。 +- [ ] 测试用户取消后 Embedding/Rerank/Chat 下游请求停止。 +- [ ] 记录 trace、阶段耗时、Token、request ID 和脱敏错误。 + +### 验收证据 + +- [ ] 回答中的每个引用均可打开到授权文档和正确页码。 +- [ ] 模型伪造、越界或不存在的引用被后端拦截。 +- [ ] 无答案题正确拒答;系统错误与“证据不足”状态不同。 +- [ ] 模型不可用时仍展示检索来源和可理解错误。 +- [ ] Playwright 覆盖上传、问答、引用、取消、失败重试和 XSS。 + +### 提交/推送节点 + +- [ ] `S5-A`:生成服务、证据 Prompt、引用验证和拒答。 +- [ ] `S5-B`:聊天/来源 UI、SSE 取消和错误状态。 +- [ ] `S5-C`:端到端、安全回归、可观测性和文档。 + +### 风险门禁 + +- 模型输出永远视为不可信数据;引用和 Markdown 必须后端/前端双层校验。 +- 前端不得获得模型 Key、工作空间凭证或未授权文档正文。 +- 不把问答系统输出表述为勘查结论、储量评估或投资建议。 + +## 9. Stage 6:OCR、表格和数据质量挑战集 + +- **状态:** `TODO` +- **预计工期:** 1–2 周 +- **依赖:** Stage 3 `DONE`;可与 Stage 4、5 部分并行 + +### 具体实现任务 + +- [ ] 建立页级数字/扫描/混合 PDF 检测。 +- [ ] 建立隔离的 OCR worker profile、资源限制和临时文件清理。 +- [ ] 实现 300 DPI 渲染、旋转/倾斜校正、PaddleOCR/PP-Structure 输出。 +- [ ] 保留 `raw_text`、修订文本、bbox、置信度、修订人和处理器 profile。 +- [ ] 建立低置信页、数值密集页、表格页人工复核队列。 +- [ ] 提取表格 JSON、Markdown、截图引用和逐行语义文本。 +- [ ] 处理跨页表、合并单元格、表头、单位和脚注;无法确认时显式失败。 +- [ ] 建立 OCR 金标准页、术语/数字/单位和表格行列对齐评测。 +- [ ] 建立图件挑战集和 UI 边界提示,不宣称已理解空间几何。 + +### 验收证据 + +- [ ] 金标准页 CER ≤ 3%,地质术语准确率 ≥ 95%,数字/符号/单位准确率 ≥ 98%。 +- [ ] 表格行列对齐 ≥ 97%,数值及单位准确率 ≥ 99%。 +- [ ] OCR 原文和人工修订文本均可追溯且不会互相覆盖。 +- [ ] 跨页表/合并单元格失败可见,不产生静默错位。 +- [ ] 只依赖图件空间关系的问题被标记为挑战集或拒答。 + +### 提交/推送节点 + +- [ ] `S6-A`:扫描检测、OCR worker 和复核队列。 +- [ ] `S6-B`:表格结构、语义行和数值校验。 +- [ ] `S6-C`:金标准质量报告、图件边界和文档。 + +### 风险门禁 + +- OCR/表格完整处理必须在出域审批前于隔离环境完成。 +- 低置信结果不得自动变为 `searchable`。 +- OCR 模型、预处理或修订变化后,outbound manifest 和向量审批必须失效重做。 + +## 10. Stage 7:正式验证集、盲测与消融 + +- **状态:** `TODO` +- **预计工期:** 3–4 周;语料标注可在 Stage 3 后提前并行 +- **依赖:** 问题编写依赖 Stage 3;最终评测依赖 Stage 4–6 `DONE` + +### 具体实现任务 + +- [ ] 建立至少 300 题:构建、开发、盲测各 100;500 题仅作为资源充足时扩展。 +- [ ] 按文档族、来源、矿种、题型、模态、难度和多跳分层。 +- [ ] 为 Gold 和全部 0–3 judgment 建立不可变源锚点。 +- [ ] 双人独立审核答案、证据和可回答性;第三名地质评审裁决。 +- [ ] 建立 `anchored_judgments.jsonl`、manifest、版本和 SHA-256。 +- [ ] 使用 Dense/词法/Hybrid/Rerank union pooling;未判定结果先补标再计分。 +- [ ] 每个 run 生成独立 qrels、anchor resolution、预测、指标和错误报告。 +- [ ] 实现 Hit、CompleteHit、EvidenceGroupRecall、MRR、nDCG、专家答案、忠实度、引用和拒答指标。 +- [ ] 对查询做 1000 次 bootstrap,输出 95% 置信区间和分层结果。 +- [ ] 在开发集完成维度、切片、重叠、前缀、Rerank、上下文、Query 改写等消融。 +- [ ] 冻结最终配置、corpus、judgment set 和盲测哈希,再执行一次正式盲测。 +- [ ] 补标或裁决变化后生成新 judgment 版本,并对比较表中全部系统重新计分。 +- [ ] 生成论文图表、成本/延迟对比和错误分类案例。 + +### 验收证据 + +- [ ] Gold、参考答案、qrels、`eval/` 和 `runs/` 永不被知识库导入器扫描。 +- [ ] 正式盲测标签在参数冻结前对开发者不可见。 +- [ ] 检索、重排、生成、引用、拒答和端到端指标均有版本化输入与配置。 +- [ ] 所有比较系统使用相同 judgment snapshot、resolver 和 corpus manifest。 +- [ ] 所有论文实验图表可由冻结运行产物重新生成。 +- [ ] 若盲测结果用于调参,该集合立即退役,最终结论改用新盲测集。 + +### 提交/推送节点 + +- [ ] `S7-A`:问题 schema、源锚点、标注工具和构建/开发集。 +- [ ] `S7-B`:盲测托管、union pooling、评测 runner 和评分规约。 +- [ ] `S7-C`:消融、配置冻结和最终盲测。 +- [ ] `S7-D`:图表、错误分析、成本和论文实验章节。 + +### 风险门禁 + +- 盲测问题、答案和标签必须由独立保管人/受控凭据管理。 +- 公开 benchmark 只能包含允许再分发或充分脱敏的内容。 +- LLM 可以辅助出题或评分,但不能同时成为唯一出题者、唯一裁判和最终评分者。 +- 工期不足时缩减到 300 题,不降低盲测隔离、双审和可复现要求。 + +## 11. Stage 8:安全加固、恢复、性能和论文定稿 + +- **状态:** `TODO` +- **预计工期:** 1 周,论文写作应贯穿前序阶段 +- **依赖:** Stage 2–7 主链路和评测结果稳定 + +### 具体实现任务 + +- [ ] 完成管理员、资料员、评测人员和普通用户 RBAC。 +- [ ] 完成对象级知识库/文档权限、审计日志和越权回归。 +- [ ] 完成上传限制、路径穿越、压缩炸弹、XSS、Prompt Injection 和限流测试。 +- [ ] 完成结构化日志脱敏、trace、模型 Token/耗时、任务和索引监控。 +- [ ] 固定镜像 digest、依赖锁和数据库迁移版本。 +- [ ] 建立数据库、允许备份文件和独立 Secret 的备份策略。 +- [ ] 在全新 Compose project 恢复数据库与文件,重建或校验索引。 +- [ ] 恢复后运行固定 20 题冒烟并记录 RTO、缺口和哈希。 +- [ ] 测量入库吞吐、查询 p50/p95、并发、错误率和每 1000 查询费用。 +- [ ] 完成论文各章、系统截图、实验图表、失败案例和局限性。 +- [ ] 完成部署手册、管理员手册、演示故障降级和恢复手册。 + +### 验收证据 + +- [ ] `make verify`、Secret 扫描、镜像检查、单元/集成/契约/E2E/安全测试通过。 +- [ ] 空白环境恢复成功,并通过固定 20 题冒烟。 +- [ ] 端到端 p95、稳态错误率、并发和成本均为实测结果。 +- [ ] 论文中的每张实验图表可追溯到 run manifest。 +- [ ] 论文明确系统边界,不夸大图件理解、找矿预测或专业决策能力。 + +### 提交/推送节点 + +- [ ] `S8-A`:RBAC、审计、安全和限流。 +- [ ] `S8-B`:备份、恢复、性能和费用报告。 +- [ ] `S8-C`:论文定稿、运维手册和 release candidate。 + +### 风险门禁 + +- 只有备份文件、没有空环境恢复证据,不得通过验收。 +- 性能数字必须注明硬件、网络、数据规模、模型配置和采样窗口。 +- release candidate 后不再做非必要依赖升级或架构替换。 + +## 12. Stage 9:答辩、发布与归档 + +- **状态:** `TODO` +- **预计工期:** 2–3 个工作日,另预留答辩排期缓冲 +- **依赖:** Stage 8 release candidate `DONE` + +### 具体实现任务 + +- [ ] 冻结答辩 release candidate、镜像 digest、迁移、模型配置和演示数据。 +- [ ] 在干净机器完整执行 `docker compose up -d --build` 和健康检查。 +- [ ] 按 [实施计划的答辩脚本](03-implementation-plan.md#8-最终答辩演示脚本) 连续彩排至少两次。 +- [ ] 准备模型临时不可用时的降级演示、预录视频和静态证据。 +- [ ] 检查答辩 PPT、论文、截图、日志和视频中没有 Secret 或受限原文。 +- [ ] 准备演示问题:正常问答、无答案、版本冲突、表格数值和局限性。 +- [ ] 生成公开归档包与内部受控归档包;两者使用不同 manifest。 +- [ ] 归档源代码、迁移、锁文件、镜像 digest、公开样例、运行配置、论文和演示材料。 +- [ ] 归档数据库/文件恢复说明、校验和、保留周期和销毁策略。 +- [ ] 删除临时 Secret、临时文件和未授权数据;按策略轮换或撤销演示 Key。 +- [ ] 创建最终 Git tag/release,推送远端并记录 commit、tag 和归档 SHA-256。 +- [ ] 记录已知限制、未完成 OPTIONAL 项、后续维护人和恢复入口。 + +### 验收证据 + +- [ ] 干净环境演示全流程成功,或按预定降级方案完成而不泄漏数据。 +- [ ] 答辩十步脚本中的每一步都有实时或冻结证据。 +- [ ] 公开包通过 Secret、版权、坐标和商业秘密复核。 +- [ ] 内部包访问控制、保留期、销毁责任人和恢复步骤明确。 +- [ ] 最终 tag/release、论文版本、镜像 digest、run manifest 和归档哈希相互可追踪。 + +### 提交/推送节点 + +- [ ] `S9-A`:答辩 release candidate、演示脚本和故障降级证据。 +- [ ] `S9-B`:论文/材料最终修订、公开与内部归档 manifest。 +- [ ] `S9-C`:最终 tag/release、远端推送和归档校验记录。 + +### 风险门禁 + +- 答辩当天不临时升级模型、依赖、数据库或重新生成整套索引。 +- 公开 release 不包含真实 Key、工作空间地址、受限语料、私有 benchmark 或可逆脱敏内容。 +- 未验证恢复的备份、未固定哈希的实验和未推送提交不能作为最终归档证据。 + +## 13. 跨阶段风险登记与停止条件 + +| 风险 | 等级 | 触发信号 | 必须动作 | 解除门槛 | +|---|---|---|---|---| +| 已暴露或疑似暴露 Key | 严重 | Key 出现在聊天、日志、Git、镜像 | 立即停止真实调用并轮换;运行 Secret 扫描 | 新 Key 仅由 Secret 注入,旧 Key 已撤销 | +| 未授权/涉密资料出域 | 严重 | 许可、坐标、保密等级或云处理权不清 | 保持隔离,不调用任何云模型 | 审核 exact outbound manifest 并进入 `CLOUD_APPROVED` | +| Gold/盲测泄漏 | 严重 | `eval/` 被入库、开发者看到盲测标签 | 停止评测,作废受污染集合 | 新盲测集、独立保管和导入 allowlist 测试通过 | +| 模型别名漂移 | 高 | 能力指纹或响应模型变化 | 提升 cache epoch,冻结新 profile,重跑受影响实验 | 新旧运行明确分版,比较不混用 | +| 向量/版本不一致 | 高 | 文本哈希、profile、维度或 active 投影不一致 | 阻止激活并运行 reconciliation | 新版本全部 READY,原子切换验证通过 | +| OCR/表格静默错误 | 高 | 数值、单位、行列错位或低置信未处理 | 降级为复核/失败,不进入检索 | 金标准和人工复核门槛通过 | +| 供应商不可用/限流 | 中 | 429、5xx、超时或区域故障 | 有界重试、熔断、展示检索降级 | smoke 恢复且错误率正常 | +| 标注工期失控 | 高 | 3–4 周内无法完成 500 题 | 缩减到 300 题,保持双审与盲测 | 300 题各 split/题型覆盖满足规范 | +| 范围蔓延 | 中 | 提前引入微服务、多模态预测或新数据库 | 回到首版非目标,记录 ADR | 核心主链路和论文证据完成后再评估 | +| 答辩环境不稳定 | 高 | 干净机器失败、模型网络不稳 | 冻结 RC、准备预录和离线证据 | 连续两次彩排通过且有降级路径 | + +## 14. 每阶段统一收尾清单 + +每个阶段完成前复制并勾选以下清单: + +- [ ] 实现范围与本阶段 TODO 一致,未静默扩展权限、依赖或服务。 +- [ ] 正常路径、失败路径和恢复路径均有验证。 +- [ ] Ruff/类型/pytest、ESLint/TypeScript/Vitest/Playwright 等适用检查通过。 +- [ ] `make verify`、Secret 扫描、Markdown 链接和 diff 检查通过。 +- [ ] 数据库迁移可在空环境向前应用,必要时有回滚/恢复说明。 +- [ ] API、配置、数据格式、ADR 和本文状态已同步。 +- [ ] 验收证据记录命令、时间、commit、数据/配置哈希和已知缺口。 +- [ ] 使用 Lore 协议提交;`Tested` 和 `Not-tested` 如实填写。 +- [ ] 推送远端并确认提交、CI 和证据可见。 +- [ ] 只有全部必选项完成后,才把阶段状态改为 `DONE` 并更新整体进度。 + +## 15. 下一步执行顺序 + +当前只推进 Stage 1,建议按以下顺序开始: + +1. 轮换并受控注入新百炼 Key,确认真实端点不写入仓库。 +2. 创建最小 Python 包、数据库初始化、Alembic 和 `db + migrate` Compose。 +3. 用 fake server 实现并验证三模型适配器契约。 +4. 运行受控 live smoke,记录 provider profile 和能力指纹。 +5. 用 20 条虚构数据完成写入、检索、重排和幂等闭环。 +6. 扩展 `make verify`,保存验收证据,完成 S1-A/S1-B/S1-C 提交与推送。 + +在 Stage 1 验收之前,不并行展开真实语料入库、完整 UI 或正式验证集调参。 diff --git a/docs/05-stage1-runbook.md b/docs/05-stage1-runbook.md new file mode 100644 index 0000000..d399aae --- /dev/null +++ b/docs/05-stage1-runbook.md @@ -0,0 +1,108 @@ +# Stage 1 PoC 运行手册 + +本手册只运行最小后端、PostgreSQL/pgvector、三模型能力探测和 20 条虚构数据 PoC。它不代表完整 React 产品已经完成。 + +## 1. 安全前置 + +聊天中曾出现的旧百炼 Key 必须先在控制台重置或删除。仓库没有保存、使用或回显该值;新的 Key 也只能进入被 `.gitignore` 排除的 `secrets/bailian_api_key`。 + +初始化本地数据库 Secret: + +```bash +make setup-hooks +make backend-sync +bash scripts/init-local-secrets.sh +``` + +脚本只生成三组彼此不同的随机 PostgreSQL 密码,文件权限为 `0600`。不带 `--with-bailian` 时不会创建模型 Key。 + +## 2. 离线数据库与向量 PoC + +离线模式使用确定性的 1024 维 feature-hash 向量和词法重排器,只验证数据治理、批处理、迁移、向量写入、权限过滤、检索、重排和幂等性,不把它当成真实模型质量。 + +```bash +docker compose up -d --build db migrate +docker compose --profile tools run --rm seed-demo-offline +docker compose --profile tools run --rm seed-demo-offline +``` + +验收输出应满足: + +- 两次均为 `status=ok`; +- `counts.chunks = counts.vectors = counts.searchable = 20`; +- 第二次运行计数不增长; +- 输出只含计数、模型标识、模式和聚合指标,不含文档正文、数据库密码或 Key。 + +Seed 的实际流程是: + +1. 只接受 `source_type=synthetic` 且处于 `LOCAL_PARSED_PENDING_CLOUD_REVIEW` 的 20 条样例; +2. 每批最多 10 条生成 1024 维向量; +3. 用 UUIDv5 生成稳定的 document/version/chunk ID; +4. 实算 `cloud_text`、`embedding_text`、profile 和 outbound manifest SHA-256; +5. 先写 manifest 成员,再绑定 `CLOUD_APPROVED`,最后原子激活文档版本; +6. 仅从当前 active、READY、同 access scope 的 searchable chunk 召回; +7. 对 Top K 候选重排并输出虚构问题的 Hit@3; +8. 相同输入再次执行使用相同 ID 和幂等 upsert,不增加行数。 + +## 3. 真实百炼能力探测 + +完成旧 Key 轮换后: + +1. 将 `.env.example` 复制为未提交的 `.env`; +2. 把 `` 替换为同一个北京地域 MaaS 工作空间 ID; +3. OpenAI 兼容 URL 以 `/compatible-mode/v1` 结尾; +4. Rerank URL 以 `/compatible-api/v1` 结尾; +5. 本项目不使用用户提供的 `/apps/anthropic` 地址; +6. 通过隐藏输入写入新 Key: + +```bash +bash scripts/init-local-secrets.sh --with-bailian +docker compose --profile tools run --rm provider-smoke +``` + +`provider-smoke` 依次验证: + +- `text-embedding-v4` 返回 1 个 1024 维有限非零向量; +- `qwen3-rerank` 返回可映射到本地候选的下标和分数; +- `deepseek-v4-flash` 在 `enable_thinking=false`、`enable_search=false` 下产生 SSE 文本。 + +探测只输出 capability、status、模型、耗时、request ID 和脱敏错误类别。URL 必须属于同一个 `*.cn-beijing.maas.aliyuncs.com` 工作空间,否则在读取 Key 前拒绝运行。 + +三模型通过后,可让同一批虚构数据走真实 Embedding/Rerank: + +```bash +docker compose --profile tools run --rm seed-demo +docker compose --profile tools run --rm seed-demo +``` + +真实模式仍只允许本仓库的虚构样例;任何真实地质报告必须等 Stage 3 的许可、涉密和 outbound manifest 审核链完成。 + +## 4. 质量门禁与排障 + +```bash +make verify +docker compose ps -a +docker compose logs --no-log-prefix migrate +``` + +常见失败: + +| 现象 | 处理 | +|---|---| +| `invalid_local_configuration` | 检查是否仍是占位 URL、两个 URL 是否同一北京工作空间、新 Key 文件是否存在 | +| migrate 等不到 DB | 检查三个数据库 Secret 是否存在且互不相同;查看 bootstrap 日志 | +| 401/403 | 不重试风暴;检查新 Key、工作空间和模型授权 | +| 429/5xx/timeout | 适配器执行有界指数退避;持续失败时保留脱敏 request ID 后停止 | +| seed 计数不是 20 | 不进入下一阶段;检查迁移、manifest 约束和事务日志 | + +停止服务但保留数据: + +```bash +docker compose down +``` + +不要在需要保留验证证据时执行 `docker compose down -v`,因为它会删除 PostgreSQL volume。 + +## 5. 当前完成边界 + +可在没有百炼 Key 时验收:配置/Secret 安全、MockTransport 契约、离线向量/重排、Compose 渲染、空卷迁移、20 条写入和幂等。只有轮换后的新 Key 完成三模型真实 smoke 与真实 seed 后,Stage 1 才能从 `IN_PROGRESS` 改为 `DONE`。 diff --git a/docs/README.md b/docs/README.md index 4b7d23c..fbe7471 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,11 +1,25 @@ # 设计文档索引 +## 当前进度 + +- Stage 0“仓库、安全和设计基线”已完成并推送。 +- Stage 1“模型与数据库 PoC”正在进行。 +- 当前整体完成度约 12%,合理区间为 10%–15%。 +- 00–03 的设计基线已完成;业务代码完成度约 8%,Stage 1 离线数据库/适配器/seed 子闭环已验收,真实百炼 smoke 与前端产品功能尚未完成。 +- 后续状态、依赖、验收证据、工期和提交节点以 [项目全生命周期 TODO](04-project-todo.md) 为准。 + +## 文档列表 + | 文档 | 内容 | 状态 | |---|---|---| | [00-overall-design.md](00-overall-design.md) | 项目范围、总体架构、技术选型、接口、数据模型与 RAG 主流程 | 设计基线完成 | | [01-data-and-evaluation.md](01-data-and-evaluation.md) | 语料治理、解析、验证集生成、向量入库测试和论文评测 | 设计基线完成 | | [02-deployment-and-security.md](02-deployment-and-security.md) | Docker Compose、配置、密钥、监控、备份和安全 | 设计基线完成 | | [03-implementation-plan.md](03-implementation-plan.md) | 阶段计划、完成定义、测试与验收清单 | 设计基线完成 | +| [04-project-todo.md](04-project-todo.md) | 从当前状态到最终答辩、发布和归档的任务看板 | Stage 1 进行中 | +| [05-stage1-runbook.md](05-stage1-runbook.md) | Secret 初始化、离线数据库/seed、真实三模型 smoke 和排障命令 | Stage 1 运行手册 | | [adr/](adr/) | 不易逆转的架构决策记录 | 持续维护 | 文档中的工作空间地址统一使用 `` 占位符;真实地址和凭证均属于部署配置,不写入仓库。 + +建议首次阅读按 00 → 01 → 02 → 03 → 04 的顺序;日常执行查看 04,运行 PoC 查看 05。 diff --git a/ops/postgres/init/.gitkeep b/ops/postgres/init/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/ops/postgres/init/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/ops/postgres/init/10-bootstrap-rag.sh b/ops/postgres/init/10-bootstrap-rag.sh new file mode 100755 index 0000000..0340184 --- /dev/null +++ b/ops/postgres/init/10-bootstrap-rag.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +umask 077 + +readonly schema_name="rag" +readonly marker_path="${PGDATA}/.rag-bootstrap-complete" + +require_file() { + local path="${1:-}" + local label="$2" + + if [[ -z "$path" || ! -r "$path" ]]; then + printf 'RAG bootstrap error: %s file is missing or unreadable\n' "$label" >&2 + return 1 + fi +} + +read_secret() { + local path="$1" + local label="$2" + local value + + require_file "$path" "$label" + value="$(<"$path")" + if [[ -z "$value" ]]; then + printf 'RAG bootstrap error: %s must not be empty\n' "$label" >&2 + return 1 + fi + if [[ "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then + printf 'RAG bootstrap error: %s must contain exactly one line\n' "$label" >&2 + return 1 + fi + if (( ${#value} < 16 )); then + printf 'RAG bootstrap error: %s must contain at least 16 characters\n' "$label" >&2 + return 1 + fi + + printf '%s' "$value" +} + +require_identifier() { + local value="${1:-}" + local label="$2" + + if [[ ! "$value" =~ ^[A-Za-z_][A-Za-z0-9_]{0,62}$ ]]; then + printf 'RAG bootstrap error: %s is not a valid PostgreSQL identifier\n' "$label" >&2 + return 1 + fi +} + +readonly bootstrap_user="${POSTGRES_USER:?POSTGRES_USER is required}" +readonly database_name="${POSTGRES_DB:?POSTGRES_DB is required}" +readonly migrator_user="${POSTGRES_MIGRATOR_USER:?POSTGRES_MIGRATOR_USER is required}" +readonly app_user="${POSTGRES_APP_USER:?POSTGRES_APP_USER is required}" + +require_identifier "$bootstrap_user" "POSTGRES_USER" +require_identifier "$database_name" "POSTGRES_DB" +require_identifier "$migrator_user" "POSTGRES_MIGRATOR_USER" +require_identifier "$app_user" "POSTGRES_APP_USER" + +if [[ "$bootstrap_user" == "$migrator_user" || + "$bootstrap_user" == "$app_user" || + "$migrator_user" == "$app_user" ]]; then + printf 'RAG bootstrap error: bootstrap, migrator, and app roles must be distinct\n' >&2 + exit 1 +fi + +# The official postgres entrypoint resolves POSTGRES_PASSWORD_FILE and then +# unsets that path before it invokes /docker-entrypoint-initdb.d scripts. +bootstrap_password="${POSTGRES_PASSWORD:-}" +if [[ -z "$bootstrap_password" || ${#bootstrap_password} -lt 16 || + "$bootstrap_password" == *$'\n'* || "$bootstrap_password" == *$'\r'* ]]; then + printf 'RAG bootstrap error: resolved bootstrap password is missing or too short\n' >&2 + exit 1 +fi +migrator_password="$(read_secret "${POSTGRES_MIGRATOR_PASSWORD_FILE:?POSTGRES_MIGRATOR_PASSWORD_FILE is required}" "migrator password")" +app_password="$(read_secret "${POSTGRES_APP_PASSWORD_FILE:?POSTGRES_APP_PASSWORD_FILE is required}" "app password")" + +if [[ "$bootstrap_password" == "$migrator_password" || + "$bootstrap_password" == "$app_password" || + "$migrator_password" == "$app_password" ]]; then + printf 'RAG bootstrap error: bootstrap, migrator, and app passwords must be distinct\n' >&2 + exit 1 +fi + +export RAG_MIGRATOR_PASSWORD="$migrator_password" +export RAG_APP_PASSWORD="$app_password" + +psql \ + --set=ON_ERROR_STOP=1 \ + --username "$bootstrap_user" \ + --dbname "$database_name" \ + --set=database_name="$database_name" \ + --set=migrator_user="$migrator_user" \ + --set=app_user="$app_user" <<'SQL' +\getenv migrator_password RAG_MIGRATOR_PASSWORD +\getenv app_password RAG_APP_PASSWORD + +BEGIN; + +CREATE EXTENSION IF NOT EXISTS vector WITH SCHEMA public; + +CREATE ROLE :"migrator_user" + LOGIN + PASSWORD :'migrator_password' + NOSUPERUSER + NOCREATEDB + NOCREATEROLE + NOINHERIT + NOBYPASSRLS; + +CREATE ROLE :"app_user" + LOGIN + PASSWORD :'app_password' + NOSUPERUSER + NOCREATEDB + NOCREATEROLE + NOINHERIT + NOBYPASSRLS; + +REVOKE ALL ON DATABASE :"database_name" FROM PUBLIC; +GRANT CONNECT ON DATABASE :"database_name" TO :"migrator_user", :"app_user"; + +REVOKE CREATE ON SCHEMA public FROM PUBLIC; +GRANT USAGE ON SCHEMA public TO :"migrator_user", :"app_user"; + +CREATE SCHEMA rag AUTHORIZATION :"migrator_user"; +GRANT USAGE, CREATE ON SCHEMA rag TO :"migrator_user"; +GRANT USAGE ON SCHEMA rag TO :"app_user"; + +ALTER ROLE :"migrator_user" IN DATABASE :"database_name" + SET search_path = rag, public; +ALTER ROLE :"app_user" IN DATABASE :"database_name" + SET search_path = rag, public; + +ALTER DEFAULT PRIVILEGES FOR ROLE :"migrator_user" IN SCHEMA rag + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO :"app_user"; +ALTER DEFAULT PRIVILEGES FOR ROLE :"migrator_user" IN SCHEMA rag + GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO :"app_user"; +ALTER DEFAULT PRIVILEGES FOR ROLE :"migrator_user" IN SCHEMA rag + REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC; +ALTER DEFAULT PRIVILEGES FOR ROLE :"migrator_user" IN SCHEMA rag + GRANT EXECUTE ON FUNCTIONS TO :"app_user"; +ALTER DEFAULT PRIVILEGES FOR ROLE :"migrator_user" IN SCHEMA rag + GRANT USAGE ON TYPES TO :"app_user"; + +COMMIT; +SQL + +unset RAG_MIGRATOR_PASSWORD RAG_APP_PASSWORD + +marker_tmp="$(mktemp "${PGDATA}/.rag-bootstrap-complete.tmp.XXXXXX")" +trap 'rm -f "$marker_tmp"' EXIT +printf 'bootstrap_version=1\nschema=%s\n' "$schema_name" >"$marker_tmp" +chmod 0600 "$marker_tmp" +mv -f "$marker_tmp" "$marker_path" +trap - EXIT + +unset bootstrap_password migrator_password app_password +printf 'RAG PostgreSQL bootstrap completed successfully.\n' diff --git a/scripts/check-secrets.sh b/scripts/check-secrets.sh index 785d249..011f581 100755 --- a/scripts/check-secrets.sh +++ b/scripts/check-secrets.sh @@ -13,10 +13,19 @@ check_file() { fi } +check_staged_file() { + local file="$1" + if git cat-file -e ":$file" 2>/dev/null \ + && grep -I -q -E "$secret_pattern" < <(git show ":$file"); then + printf 'Potential secret detected in staged content: %s\n' "$file" >&2 + found=1 + fi +} + case "$mode" in --staged) while IFS= read -r -d '' file; do - check_file "$file" + check_staged_file "$file" done < <(git diff --cached --name-only --diff-filter=ACMR -z) ;; --all) diff --git a/scripts/init-local-secrets.sh b/scripts/init-local-secrets.sh new file mode 100755 index 0000000..0796bca --- /dev/null +++ b/scripts/init-local-secrets.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +umask 077 +secret_dir="${SECRET_DIR:-secrets}" +mkdir -p "$secret_dir" +chmod 700 "$secret_dir" + +generate_secret() { + local name="$1" + local path="$secret_dir/$name" + if [[ -s "$path" ]]; then + printf 'Kept existing local secret: %s\n' "$path" + return + fi + openssl rand -hex 32 >"$path" + chmod 600 "$path" + printf 'Created local secret: %s\n' "$path" +} + +generate_secret postgres_bootstrap_password +generate_secret postgres_migrator_password +generate_secret postgres_app_password + +if [[ "${1:-}" == "--with-bailian" ]]; then + bailian_path="$secret_dir/bailian_api_key" + if [[ -s "$bailian_path" ]]; then + printf 'Kept existing local secret: %s\n' "$bailian_path" + exit 0 + fi + + printf 'Paste the NEWLY ROTATED Bailian API key (input is hidden): ' >&2 + IFS= read -r -s bailian_key + printf '\n' >&2 + if [[ ! "$bailian_key" =~ ^sk-[A-Za-z0-9_-]{20,}$ ]]; then + printf 'Refused: the value does not look like a Bailian API key.\n' >&2 + exit 2 + fi + printf '%s' "$bailian_key" >"$bailian_path" + chmod 600 "$bailian_path" + unset bailian_key + printf 'Created local Bailian secret without echoing its value: %s\n' "$bailian_path" +else + printf 'Bailian key not created. Rotate the exposed key, then rerun with --with-bailian.\n' +fi