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

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

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

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

Confidence: high

Scope-risk: moderate

Reversibility: clean

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

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

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

View File

@@ -1,13 +1,19 @@
name: secret-scan name: verify
on: on:
push: push:
pull_request: pull_request:
jobs: jobs:
scan: verify:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Verify design and scan repository files - uses: astral-sh/setup-uv@v6
run: make verify-design 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

69
.gitignore vendored Normal file
View File

@@ -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/

View File

@@ -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: setup-hooks:
git config core.hooksPath .githooks git config core.hooksPath .githooks
@@ -12,6 +14,8 @@ docs-check:
test -f docs/01-data-and-evaluation.md test -f docs/01-data-and-evaluation.md
test -f docs/02-deployment-and-security.md test -f docs/02-deployment-and-security.md
test -f docs/03-implementation-plan.md test -f docs/03-implementation-plan.md
test -f docs/04-project-todo.md
test -f docs/05-stage1-runbook.md
links-check: links-check:
python3 scripts/check_markdown_links.py python3 scripts/check_markdown_links.py
@@ -20,6 +24,29 @@ verify-design: check-secrets docs-check links-check
git diff --check git diff --check
git diff --cached --check git diff --cached --check
# During the design stage this aliases the design checks. Implementation stages backend-sync:
# must extend it with backend/frontend lint, types, tests, and image checks. cd backend && UV_CACHE_DIR=.uv-cache uv sync --all-groups --frozen
verify: verify-design
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

View File

@@ -2,7 +2,7 @@
本仓库用于“基于 RAG 的地质找矿知识问答系统构建与应用”毕业设计。系统计划采用 Python/FastAPI 后端、React/TypeScript 前端、PostgreSQL + pgvector 向量存储,以及阿里云百炼的 `text-embedding-v4``qwen3-rerank``deepseek-v4-flash` 本仓库用于“基于 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 仍待轮换后的新 KeyReact 产品功能尚未开始。完整方案、状态和评测方法见 [docs/README.md](docs/README.md),仓库不会把 PoC 伪装成已经完成的产品。
## 目录 ## 目录
@@ -42,4 +42,19 @@
- [数据与评测设计](docs/01-data-and-evaluation.md) - [数据与评测设计](docs/01-data-and-evaluation.md)
- [部署与安全设计](docs/02-deployment-and-security.md) - [部署与安全设计](docs/02-deployment-and-security.md)
- [实施计划与验收](docs/03-implementation-plan.md) - [实施计划与验收](docs/03-implementation-plan.md)
- [全生命周期 TODO 与进度](docs/04-project-todo.md)
- [Stage 1 PoC 运行手册](docs/05-stage1-runbook.md)
- [架构决策记录](docs/adr/README.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) 操作。

10
backend/.dockerignore Normal file
View File

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

33
backend/Dockerfile Normal file
View File

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

46
backend/alembic.ini Normal file
View File

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

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

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

View File

@@ -1 +0,0 @@

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +0,0 @@

View File

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

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

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

View File

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

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

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

View File

@@ -1 +0,0 @@

View File

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

View File

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

View File

@@ -1 +0,0 @@

View File

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

View File

@@ -1 +0,0 @@

View File

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

View File

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

View File

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

View File

@@ -1 +0,0 @@

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

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

View File

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

File diff suppressed because it is too large Load Diff

58
backend/pyproject.toml Normal file
View File

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

View File

@@ -1 +0,0 @@

View File

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

View File

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

View File

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

View File

@@ -1 +0,0 @@

View File

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

View File

@@ -1 +0,0 @@

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

680
backend/uv.lock generated Normal file
View File

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

160
compose.yaml Normal file
View File

@@ -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

View File

@@ -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` 策略完成审批绑定。文件中的普通字段不能直接越过云审批。
这些样例不能用于证明真实地质问答质量;正式评测必须使用取得授权、双人标注并隔离盲测的数据。

View File

@@ -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"}

View File

@@ -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}

560
docs/04-project-todo.md Normal file
View File

@@ -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前端产品功能尚未实现 |
| 当前预计剩余工期 | 约 913 周,含 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 27 主链路稳定 | 论文各章随阶段持续写作 | 未恢复验证就声称备份可用 |
| Stage 9 | Stage 8 发布候选通过 | 演讲稿和演示素材提前准备 | 在最终演示前临时升级依赖或模型配置 |
## 3. Stage 0仓库、安全和设计基线
- **状态:** `DONE`
- **工期:** 已完成
- **依赖:** 无
### 已完成任务
- [x] 建立仓库说明、贡献约定、安全说明和 AGENTS 约束。
- [x] 建立 `backend/``frontend/``data/``ops/``scripts/``docs/` 结构。
- [x] 完成 0003 总体、数据评测、部署安全和实施计划设计基线。
- [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`
- **预计工期:** 12 个工作日;外部密钥或网络未就绪时暂停计时
- **依赖:** 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/209 个可回答虚构问题 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`
- **预计工期:** 34 个工作日
- **依赖:** 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 6OCR、表格和数据质量挑战集
- **状态:** `TODO`
- **预计工期:** 12 周
- **依赖:** 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`
- **预计工期:** 34 周;语料标注可在 Stage 3 后提前并行
- **依赖:** 问题编写依赖 Stage 3最终评测依赖 Stage 46 `DONE`
### 具体实现任务
- [ ] 建立至少 300 题:构建、开发、盲测各 100500 题仅作为资源充足时扩展。
- [ ] 按文档族、来源、矿种、题型、模态、难度和多跳分层。
- [ ] 为 Gold 和全部 03 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 27 主链路和评测结果稳定
### 具体实现任务
- [ ] 完成管理员、资料员、评测人员和普通用户 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`
- **预计工期:** 23 个工作日,另预留答辩排期缓冲
- **依赖:** 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 恢复且错误率正常 |
| 标注工期失控 | 高 | 34 周内无法完成 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 或正式验证集调参。

108
docs/05-stage1-runbook.md Normal file
View File

@@ -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.`<workspace-id>` 替换为同一个北京地域 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`

View File

@@ -1,11 +1,25 @@
# 设计文档索引 # 设计文档索引
## 当前进度
- Stage 0“仓库、安全和设计基线”已完成并推送。
- Stage 1“模型与数据库 PoC”正在进行。
- 当前整体完成度约 12%,合理区间为 10%15%。
- 0003 的设计基线已完成;业务代码完成度约 8%Stage 1 离线数据库/适配器/seed 子闭环已验收,真实百炼 smoke 与前端产品功能尚未完成。
- 后续状态、依赖、验收证据、工期和提交节点以 [项目全生命周期 TODO](04-project-todo.md) 为准。
## 文档列表
| 文档 | 内容 | 状态 | | 文档 | 内容 | 状态 |
|---|---|---| |---|---|---|
| [00-overall-design.md](00-overall-design.md) | 项目范围、总体架构、技术选型、接口、数据模型与 RAG 主流程 | 设计基线完成 | | [00-overall-design.md](00-overall-design.md) | 项目范围、总体架构、技术选型、接口、数据模型与 RAG 主流程 | 设计基线完成 |
| [01-data-and-evaluation.md](01-data-and-evaluation.md) | 语料治理、解析、验证集生成、向量入库测试和论文评测 | 设计基线完成 | | [01-data-and-evaluation.md](01-data-and-evaluation.md) | 语料治理、解析、验证集生成、向量入库测试和论文评测 | 设计基线完成 |
| [02-deployment-and-security.md](02-deployment-and-security.md) | Docker Compose、配置、密钥、监控、备份和安全 | 设计基线完成 | | [02-deployment-and-security.md](02-deployment-and-security.md) | Docker Compose、配置、密钥、监控、备份和安全 | 设计基线完成 |
| [03-implementation-plan.md](03-implementation-plan.md) | 阶段计划、完成定义、测试与验收清单 | 设计基线完成 | | [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/) | 不易逆转的架构决策记录 | 持续维护 | | [adr/](adr/) | 不易逆转的架构决策记录 | 持续维护 |
文档中的工作空间地址统一使用 `<workspace-id>` 占位符;真实地址和凭证均属于部署配置,不写入仓库。 文档中的工作空间地址统一使用 `<workspace-id>` 占位符;真实地址和凭证均属于部署配置,不写入仓库。
建议首次阅读按 00 → 01 → 02 → 03 → 04 的顺序;日常执行查看 04运行 PoC 查看 05。

View File

@@ -1 +0,0 @@

View File

@@ -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'

View File

@@ -13,10 +13,19 @@ check_file() {
fi 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 case "$mode" in
--staged) --staged)
while IFS= read -r -d '' file; do while IFS= read -r -d '' file; do
check_file "$file" check_staged_file "$file"
done < <(git diff --cached --name-only --diff-filter=ACMR -z) done < <(git diff --cached --name-only --diff-filter=ACMR -z)
;; ;;
--all) --all)

45
scripts/init-local-secrets.sh Executable file
View File

@@ -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