Make the first RAG slice executable without risking production data
Some checks failed
verify / verify (push) Has been cancelled
Some checks failed
verify / verify (push) Has been cancelled
The Stage 1 foundation now proves provider contracts with mocks and validates PostgreSQL/pgvector ingestion, approval binding, retrieval, reranking, and idempotency using only synthetic data. Live Bailian validation remains gated on rotating the exposed key. Constraint: The key shown in chat is compromised and cannot be used or committed Rejected: Mark Stage 1 complete from mock and offline results | real three-model smoke is still required Confidence: high Scope-risk: moderate Reversibility: clean Directive: Do not enable real-data ingestion until Stage 3 cloud approval and outbound manifest controls are enforced end to end Tested: make verify; 41 pytest tests; strict mypy; Ruff; Compose config; pinned image build; empty-volume migration; role denial; two idempotent 20-vector seeds; database restart persistence Not-tested: Live Bailian calls require a newly rotated key; React product UI is not implemented
This commit is contained in:
@@ -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
69
.gitignore
vendored
Normal 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/
|
||||||
35
Makefile
35
Makefile
@@ -1,4 +1,6 @@
|
|||||||
.PHONY: setup-hooks check-secrets docs-check links-check verify-design verify
|
.PHONY: setup-hooks check-secrets docs-check links-check verify-design backend-sync \
|
||||||
|
backend-format backend-lint backend-type backend-test backend-check compose-check \
|
||||||
|
verify-ci verify
|
||||||
|
|
||||||
setup-hooks:
|
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
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -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 仍待轮换后的新 Key,React 产品功能尚未开始。完整方案、状态和评测方法见 [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
10
backend/.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.venv
|
||||||
|
.uv-cache
|
||||||
|
__pycache__
|
||||||
|
.pytest_cache
|
||||||
|
.mypy_cache
|
||||||
|
.ruff_cache
|
||||||
|
tests
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.DS_Store
|
||||||
33
backend/Dockerfile
Normal file
33
backend/Dockerfile
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
|
||||||
|
FROM ghcr.io/astral-sh/uv:0.11.3@sha256:90bbb3c16635e9627f49eec6539f956d70746c409209041800a0280b93152823 AS uv
|
||||||
|
|
||||||
|
FROM python:3.12-slim-bookworm@sha256:8a7e7cc04fd3e2bd787f7f24e22d5d119aa590d429b50c95dfe12b3abe52f48b AS runtime
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
UV_COMPILE_BYTECODE=1 \
|
||||||
|
UV_LINK_MODE=copy \
|
||||||
|
PATH="/app/.venv/bin:$PATH"
|
||||||
|
|
||||||
|
RUN groupadd --system --gid 10001 app \
|
||||||
|
&& useradd --system --uid 10001 --gid app --home-dir /app app
|
||||||
|
|
||||||
|
COPY --from=uv /uv /uvx /bin/
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
RUN uv sync --frozen --no-dev --no-install-project
|
||||||
|
|
||||||
|
COPY alembic.ini ./
|
||||||
|
COPY migrations ./migrations
|
||||||
|
COPY app ./app
|
||||||
|
COPY README.md ./
|
||||||
|
|
||||||
|
RUN uv sync --frozen --no-dev \
|
||||||
|
&& chown -R app:app /app
|
||||||
|
|
||||||
|
USER app
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["python", "-m", "app.main"]
|
||||||
46
backend/alembic.ini
Normal file
46
backend/alembic.ini
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = %(here)s/migrations
|
||||||
|
prepend_sys_path = %(here)s
|
||||||
|
path_separator = os
|
||||||
|
version_path_separator = os
|
||||||
|
output_encoding = utf-8
|
||||||
|
|
||||||
|
# The runtime URL is assembled in migrations/env.py from POSTGRES_* and
|
||||||
|
# POSTGRES_PASSWORD_FILE. Never place a credential in this file.
|
||||||
|
sqlalchemy.url = postgresql+psycopg://invalid:invalid@invalid/invalid
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
3
backend/app/__init__.py
Normal file
3
backend/app/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""Geological RAG backend package."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
11
backend/app/adapters/bailian/__init__.py
Normal file
11
backend/app/adapters/bailian/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"""Alibaba Cloud Model Studio provider adapters."""
|
||||||
|
|
||||||
|
from app.adapters.bailian.chat import BailianChatAdapter
|
||||||
|
from app.adapters.bailian.embedding import BailianEmbeddingAdapter
|
||||||
|
from app.adapters.bailian.rerank import BailianRerankerAdapter
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BailianChatAdapter",
|
||||||
|
"BailianEmbeddingAdapter",
|
||||||
|
"BailianRerankerAdapter",
|
||||||
|
]
|
||||||
382
backend/app/adapters/bailian/_base.py
Normal file
382
backend/app/adapters/bailian/_base.py
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
"""Shared HTTP and validation machinery for Alibaba Cloud Model Studio."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from email.utils import parsedate_to_datetime
|
||||||
|
from time import perf_counter
|
||||||
|
from typing import Any, Self
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.ports.model_providers import (
|
||||||
|
ModelProviderError,
|
||||||
|
ProviderErrorKind,
|
||||||
|
ProviderUsage,
|
||||||
|
TokenCounter,
|
||||||
|
)
|
||||||
|
|
||||||
|
_SAFE_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:/-]{0,127}$")
|
||||||
|
_BAILIAN_BEIJING_HOST = re.compile(
|
||||||
|
r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.cn-beijing\.maas\.aliyuncs\.com$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def conservative_utf8_token_count(text: str) -> int:
|
||||||
|
"""Return a dependency-free conservative upper bound for byte-BPE tokens.
|
||||||
|
|
||||||
|
Production may inject the provider tokenizer through ``token_counter``. A
|
||||||
|
UTF-8 byte count is deliberately conservative and, unlike a word count,
|
||||||
|
does not undercount Chinese text or byte-fallback tokens.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return len(text.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def count_tokens(
|
||||||
|
counter: TokenCounter,
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
operation: str,
|
||||||
|
) -> int:
|
||||||
|
try:
|
||||||
|
count = counter(text)
|
||||||
|
except Exception:
|
||||||
|
raise sanitized_error(
|
||||||
|
operation=operation,
|
||||||
|
kind=ProviderErrorKind.INVALID_REQUEST,
|
||||||
|
provider_code="token_count_failed",
|
||||||
|
) from None
|
||||||
|
if isinstance(count, bool) or not isinstance(count, int) or count < 0:
|
||||||
|
raise sanitized_error(
|
||||||
|
operation=operation,
|
||||||
|
kind=ProviderErrorKind.INVALID_REQUEST,
|
||||||
|
provider_code="invalid_token_count",
|
||||||
|
)
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def sanitized_error(
|
||||||
|
*,
|
||||||
|
operation: str,
|
||||||
|
kind: ProviderErrorKind,
|
||||||
|
status_code: int | None = None,
|
||||||
|
provider_code: str | None = None,
|
||||||
|
request_id: str | None = None,
|
||||||
|
retryable: bool = False,
|
||||||
|
) -> ModelProviderError:
|
||||||
|
return ModelProviderError(
|
||||||
|
operation=operation,
|
||||||
|
kind=kind,
|
||||||
|
status_code=status_code,
|
||||||
|
provider_code=provider_code,
|
||||||
|
request_id=request_id,
|
||||||
|
retryable=retryable,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def invalid_request(operation: str, code: str) -> ModelProviderError:
|
||||||
|
return sanitized_error(
|
||||||
|
operation=operation,
|
||||||
|
kind=ProviderErrorKind.INVALID_REQUEST,
|
||||||
|
provider_code=code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def invalid_response(operation: str, code: str) -> ModelProviderError:
|
||||||
|
return sanitized_error(
|
||||||
|
operation=operation,
|
||||||
|
kind=ProviderErrorKind.INVALID_RESPONSE,
|
||||||
|
provider_code=code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_usage(value: Any) -> ProviderUsage:
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
return ProviderUsage()
|
||||||
|
return ProviderUsage(
|
||||||
|
input_tokens=_first_nonnegative_int(
|
||||||
|
value.get("prompt_tokens"),
|
||||||
|
value.get("input_tokens"),
|
||||||
|
),
|
||||||
|
output_tokens=_first_nonnegative_int(
|
||||||
|
value.get("completion_tokens"),
|
||||||
|
value.get("output_tokens"),
|
||||||
|
),
|
||||||
|
total_tokens=_nonnegative_int(value.get("total_tokens")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def response_model(
|
||||||
|
body: Mapping[str, Any],
|
||||||
|
requested_model: str,
|
||||||
|
*,
|
||||||
|
sensitive_values: Sequence[str] = (),
|
||||||
|
) -> str:
|
||||||
|
return safe_identifier(body.get("model"), sensitive_values=sensitive_values) or requested_model
|
||||||
|
|
||||||
|
|
||||||
|
def safe_identifier(
|
||||||
|
value: Any,
|
||||||
|
*,
|
||||||
|
sensitive_values: Sequence[str] = (),
|
||||||
|
) -> str | None:
|
||||||
|
if not isinstance(value, str) or not _SAFE_IDENTIFIER.fullmatch(value):
|
||||||
|
return None
|
||||||
|
if any(value in sensitive or sensitive in value for sensitive in sensitive_values if sensitive):
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def extract_request_id(
|
||||||
|
body: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
sensitive_values: Sequence[str] = (),
|
||||||
|
) -> str | None:
|
||||||
|
return safe_identifier(
|
||||||
|
body.get("id") or body.get("request_id"),
|
||||||
|
sensitive_values=sensitive_values,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _nonnegative_int(value: Any) -> int | None:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||||
|
return None
|
||||||
|
return int(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _first_nonnegative_int(*values: Any) -> int | None:
|
||||||
|
for value in values:
|
||||||
|
parsed = _nonnegative_int(value)
|
||||||
|
if parsed is not None:
|
||||||
|
return parsed
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class JsonResponse:
|
||||||
|
body: Mapping[str, Any]
|
||||||
|
elapsed_ms: float
|
||||||
|
|
||||||
|
|
||||||
|
class BailianHttpAdapter:
|
||||||
|
"""Minimal async HTTP client with sanitized error translation."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
api_key: str,
|
||||||
|
base_url: str,
|
||||||
|
expected_path: str,
|
||||||
|
http_client: httpx.AsyncClient | None,
|
||||||
|
timeout_seconds: float,
|
||||||
|
max_retries: int = 0,
|
||||||
|
retry_base_seconds: float = 0.5,
|
||||||
|
) -> None:
|
||||||
|
if not api_key or api_key != api_key.strip():
|
||||||
|
raise invalid_request("configuration", "invalid_api_key_value")
|
||||||
|
if timeout_seconds <= 0:
|
||||||
|
raise invalid_request("configuration", "invalid_timeout")
|
||||||
|
if isinstance(max_retries, bool) or not isinstance(max_retries, int) or max_retries < 0:
|
||||||
|
raise invalid_request("configuration", "invalid_max_retries")
|
||||||
|
if retry_base_seconds < 0:
|
||||||
|
raise invalid_request("configuration", "invalid_retry_base")
|
||||||
|
|
||||||
|
self._base_url = _validated_base_url(base_url, expected_path)
|
||||||
|
self._api_key = api_key
|
||||||
|
self._owns_client = http_client is None
|
||||||
|
self._client = http_client or httpx.AsyncClient(
|
||||||
|
timeout=httpx.Timeout(timeout_seconds),
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
self._max_retries = max_retries
|
||||||
|
self._retry_base_seconds = retry_base_seconds
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
if self._owns_client:
|
||||||
|
await self._client.aclose()
|
||||||
|
|
||||||
|
async def __aenter__(self) -> Self:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_: object) -> None:
|
||||||
|
await self.aclose()
|
||||||
|
|
||||||
|
def _url(self, path: str) -> str:
|
||||||
|
return f"{self._base_url}/{path.lstrip('/')}"
|
||||||
|
|
||||||
|
def _headers(self) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {self._api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _post_json(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
operation: str,
|
||||||
|
path: str,
|
||||||
|
payload: Mapping[str, Any],
|
||||||
|
sensitive_values: Sequence[str],
|
||||||
|
) -> JsonResponse:
|
||||||
|
started = perf_counter()
|
||||||
|
for attempt in range(self._max_retries + 1):
|
||||||
|
try:
|
||||||
|
response = await self._client.post(
|
||||||
|
self._url(path),
|
||||||
|
headers=self._headers(),
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
error = sanitized_error(
|
||||||
|
operation=operation,
|
||||||
|
kind=ProviderErrorKind.TIMEOUT,
|
||||||
|
provider_code="request_timeout",
|
||||||
|
retryable=True,
|
||||||
|
)
|
||||||
|
if await self._maybe_retry(error, attempt=attempt, response=None):
|
||||||
|
continue
|
||||||
|
raise error from None
|
||||||
|
except httpx.HTTPError:
|
||||||
|
error = sanitized_error(
|
||||||
|
operation=operation,
|
||||||
|
kind=ProviderErrorKind.TRANSPORT,
|
||||||
|
provider_code="transport_error",
|
||||||
|
retryable=True,
|
||||||
|
)
|
||||||
|
if await self._maybe_retry(error, attempt=attempt, response=None):
|
||||||
|
continue
|
||||||
|
raise error from None
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
try:
|
||||||
|
self._raise_http_error(
|
||||||
|
operation=operation,
|
||||||
|
response=response,
|
||||||
|
sensitive_values=(*sensitive_values, self._api_key),
|
||||||
|
)
|
||||||
|
except ModelProviderError as error:
|
||||||
|
if await self._maybe_retry(error, attempt=attempt, response=response):
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except ValueError:
|
||||||
|
raise invalid_response(operation, "invalid_json") from None
|
||||||
|
if not isinstance(body, Mapping):
|
||||||
|
raise invalid_response(operation, "invalid_json_object")
|
||||||
|
return JsonResponse(body=body, elapsed_ms=(perf_counter() - started) * 1000)
|
||||||
|
|
||||||
|
raise AssertionError("bounded provider retry loop exhausted unexpectedly")
|
||||||
|
|
||||||
|
async def _maybe_retry(
|
||||||
|
self,
|
||||||
|
error: ModelProviderError,
|
||||||
|
*,
|
||||||
|
attempt: int,
|
||||||
|
response: httpx.Response | None,
|
||||||
|
) -> bool:
|
||||||
|
if not error.retryable or attempt >= self._max_retries:
|
||||||
|
return False
|
||||||
|
await asyncio.sleep(self._retry_delay(attempt=attempt, response=response))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _retry_delay(self, *, attempt: int, response: httpx.Response | None) -> float:
|
||||||
|
if response is not None:
|
||||||
|
retry_after = response.headers.get("Retry-After")
|
||||||
|
if retry_after:
|
||||||
|
parsed_delay = _parse_retry_after(retry_after)
|
||||||
|
if parsed_delay is not None:
|
||||||
|
return min(max(parsed_delay, 0.0), 30.0)
|
||||||
|
base_delay = min(self._retry_base_seconds * (2**attempt), 30.0)
|
||||||
|
if base_delay == 0:
|
||||||
|
return 0.0
|
||||||
|
jitter = base_delay * (float(secrets.randbelow(251)) / 1000.0)
|
||||||
|
return float(min(base_delay + jitter, 30.0))
|
||||||
|
|
||||||
|
def _raise_http_error(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
operation: str,
|
||||||
|
response: httpx.Response,
|
||||||
|
sensitive_values: Sequence[str],
|
||||||
|
) -> None:
|
||||||
|
body: Mapping[str, Any] = {}
|
||||||
|
try:
|
||||||
|
decoded = response.json()
|
||||||
|
if isinstance(decoded, Mapping):
|
||||||
|
body = decoded
|
||||||
|
except (ValueError, httpx.ResponseNotRead):
|
||||||
|
pass
|
||||||
|
|
||||||
|
nested_error = body.get("error")
|
||||||
|
error_body = nested_error if isinstance(nested_error, Mapping) else body
|
||||||
|
code = safe_identifier(
|
||||||
|
error_body.get("code"),
|
||||||
|
sensitive_values=sensitive_values,
|
||||||
|
)
|
||||||
|
request_id = extract_request_id(body, sensitive_values=sensitive_values)
|
||||||
|
kind, retryable = _kind_for_status(response.status_code)
|
||||||
|
raise sanitized_error(
|
||||||
|
operation=operation,
|
||||||
|
kind=kind,
|
||||||
|
status_code=response.status_code,
|
||||||
|
provider_code=code,
|
||||||
|
request_id=request_id,
|
||||||
|
retryable=retryable,
|
||||||
|
) from None
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_base_url(base_url: str, expected_path: str) -> str:
|
||||||
|
if not base_url or base_url != base_url.strip():
|
||||||
|
raise invalid_request("configuration", "invalid_base_url")
|
||||||
|
parsed = urlsplit(base_url)
|
||||||
|
normalized_path = parsed.path.rstrip("/")
|
||||||
|
if (
|
||||||
|
parsed.scheme != "https"
|
||||||
|
or not parsed.hostname
|
||||||
|
or not _BAILIAN_BEIJING_HOST.fullmatch(parsed.hostname)
|
||||||
|
or parsed.username is not None
|
||||||
|
or parsed.password is not None
|
||||||
|
or parsed.query
|
||||||
|
or parsed.fragment
|
||||||
|
or normalized_path != expected_path
|
||||||
|
):
|
||||||
|
raise invalid_request("configuration", "invalid_base_url")
|
||||||
|
return base_url.rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _kind_for_status(status_code: int) -> tuple[ProviderErrorKind, bool]:
|
||||||
|
if status_code == 400:
|
||||||
|
return ProviderErrorKind.INVALID_REQUEST, False
|
||||||
|
if status_code == 401:
|
||||||
|
return ProviderErrorKind.AUTHENTICATION, False
|
||||||
|
if status_code == 403:
|
||||||
|
return ProviderErrorKind.PERMISSION_DENIED, False
|
||||||
|
if status_code == 404:
|
||||||
|
return ProviderErrorKind.NOT_FOUND, False
|
||||||
|
if status_code == 408:
|
||||||
|
return ProviderErrorKind.TIMEOUT, True
|
||||||
|
if status_code == 429:
|
||||||
|
return ProviderErrorKind.RATE_LIMITED, True
|
||||||
|
return ProviderErrorKind.UPSTREAM, status_code >= 500
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_retry_after(value: str) -> float | None:
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
retry_at = parsedate_to_datetime(value)
|
||||||
|
except (TypeError, ValueError, OverflowError):
|
||||||
|
return None
|
||||||
|
if retry_at.tzinfo is None:
|
||||||
|
retry_at = retry_at.replace(tzinfo=UTC)
|
||||||
|
return (retry_at - datetime.now(UTC)).total_seconds()
|
||||||
326
backend/app/adapters/bailian/chat.py
Normal file
326
backend/app/adapters/bailian/chat.py
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
"""Alibaba Cloud Model Studio OpenAI-compatible chat adapter."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections.abc import AsyncIterator, Mapping, Sequence
|
||||||
|
from time import perf_counter
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.adapters.bailian._base import (
|
||||||
|
BailianHttpAdapter,
|
||||||
|
extract_request_id,
|
||||||
|
invalid_request,
|
||||||
|
invalid_response,
|
||||||
|
parse_usage,
|
||||||
|
response_model,
|
||||||
|
sanitized_error,
|
||||||
|
)
|
||||||
|
from app.ports.model_providers import (
|
||||||
|
ChatCompletionResult,
|
||||||
|
ChatMessage,
|
||||||
|
ChatStreamEvent,
|
||||||
|
ModelProviderError,
|
||||||
|
ProviderErrorKind,
|
||||||
|
ProviderUsage,
|
||||||
|
)
|
||||||
|
|
||||||
|
_ALLOWED_ROLES = frozenset({"system", "user", "assistant"})
|
||||||
|
|
||||||
|
|
||||||
|
class BailianChatAdapter(BailianHttpAdapter):
|
||||||
|
"""Call chat completions with thinking and web search forcibly disabled."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
api_key: str,
|
||||||
|
base_url: str,
|
||||||
|
model: str = "deepseek-v4-flash",
|
||||||
|
http_client: httpx.AsyncClient | None = None,
|
||||||
|
timeout_seconds: float = 60.0,
|
||||||
|
max_retries: int = 0,
|
||||||
|
retry_base_seconds: float = 0.5,
|
||||||
|
) -> None:
|
||||||
|
if not model or model != model.strip():
|
||||||
|
raise invalid_request("chat.configuration", "invalid_model")
|
||||||
|
super().__init__(
|
||||||
|
api_key=api_key,
|
||||||
|
base_url=base_url,
|
||||||
|
expected_path="/compatible-mode/v1",
|
||||||
|
http_client=http_client,
|
||||||
|
timeout_seconds=timeout_seconds,
|
||||||
|
max_retries=max_retries,
|
||||||
|
retry_base_seconds=retry_base_seconds,
|
||||||
|
)
|
||||||
|
self._model = model
|
||||||
|
|
||||||
|
async def complete(
|
||||||
|
self,
|
||||||
|
messages: Sequence[ChatMessage],
|
||||||
|
*,
|
||||||
|
max_tokens: int,
|
||||||
|
) -> ChatCompletionResult:
|
||||||
|
operation = "chat.complete"
|
||||||
|
validated_messages = self._validate_messages(
|
||||||
|
messages,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
operation=operation,
|
||||||
|
)
|
||||||
|
payload = self._payload(validated_messages, max_tokens=max_tokens, stream=False)
|
||||||
|
sensitive_values = tuple(message.content for message in validated_messages)
|
||||||
|
response = await self._post_json(
|
||||||
|
operation=operation,
|
||||||
|
path="chat/completions",
|
||||||
|
payload=payload,
|
||||||
|
sensitive_values=sensitive_values,
|
||||||
|
)
|
||||||
|
content, finish_reason = self._parse_completion(
|
||||||
|
response.body,
|
||||||
|
operation=operation,
|
||||||
|
)
|
||||||
|
return ChatCompletionResult(
|
||||||
|
content=content,
|
||||||
|
finish_reason=finish_reason,
|
||||||
|
model=response_model(
|
||||||
|
response.body,
|
||||||
|
self._model,
|
||||||
|
sensitive_values=(*sensitive_values, self._api_key),
|
||||||
|
),
|
||||||
|
request_id=extract_request_id(
|
||||||
|
response.body,
|
||||||
|
sensitive_values=(*sensitive_values, self._api_key),
|
||||||
|
),
|
||||||
|
usage=parse_usage(response.body.get("usage")),
|
||||||
|
elapsed_ms=response.elapsed_ms,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stream(
|
||||||
|
self,
|
||||||
|
messages: Sequence[ChatMessage],
|
||||||
|
*,
|
||||||
|
max_tokens: int,
|
||||||
|
) -> AsyncIterator[ChatStreamEvent]:
|
||||||
|
operation = "chat.stream"
|
||||||
|
validated_messages = self._validate_messages(
|
||||||
|
messages,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
operation=operation,
|
||||||
|
)
|
||||||
|
payload = self._payload(validated_messages, max_tokens=max_tokens, stream=True)
|
||||||
|
sensitive_values = tuple(message.content for message in validated_messages)
|
||||||
|
started = perf_counter()
|
||||||
|
for attempt in range(self._max_retries + 1):
|
||||||
|
emitted = False
|
||||||
|
try:
|
||||||
|
async with self._client.stream(
|
||||||
|
"POST",
|
||||||
|
self._url("chat/completions"),
|
||||||
|
headers=self._headers(),
|
||||||
|
json=payload,
|
||||||
|
) as response:
|
||||||
|
if response.status_code >= 400:
|
||||||
|
await response.aread()
|
||||||
|
try:
|
||||||
|
self._raise_http_error(
|
||||||
|
operation=operation,
|
||||||
|
response=response,
|
||||||
|
sensitive_values=(*sensitive_values, self._api_key),
|
||||||
|
)
|
||||||
|
except ModelProviderError as error:
|
||||||
|
if await self._maybe_retry(
|
||||||
|
error,
|
||||||
|
attempt=attempt,
|
||||||
|
response=response,
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
async for line in response.aiter_lines():
|
||||||
|
if not line or line.startswith(":"):
|
||||||
|
continue
|
||||||
|
if not line.startswith("data:"):
|
||||||
|
raise invalid_response(operation, "invalid_sse_event")
|
||||||
|
raw_data = line[5:].strip()
|
||||||
|
if raw_data == "[DONE]":
|
||||||
|
return
|
||||||
|
event_body = self._decode_stream_event(raw_data, operation=operation)
|
||||||
|
event = self._parse_stream_event(
|
||||||
|
event_body,
|
||||||
|
operation=operation,
|
||||||
|
sensitive_values=sensitive_values,
|
||||||
|
elapsed_ms=(perf_counter() - started) * 1000,
|
||||||
|
)
|
||||||
|
emitted = True
|
||||||
|
yield event
|
||||||
|
return
|
||||||
|
except ModelProviderError as error:
|
||||||
|
if not emitted and await self._maybe_retry(
|
||||||
|
error,
|
||||||
|
attempt=attempt,
|
||||||
|
response=None,
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
timeout_error = sanitized_error(
|
||||||
|
operation=operation,
|
||||||
|
kind=ProviderErrorKind.TIMEOUT,
|
||||||
|
provider_code="request_timeout",
|
||||||
|
retryable=True,
|
||||||
|
)
|
||||||
|
if not emitted and await self._maybe_retry(
|
||||||
|
timeout_error,
|
||||||
|
attempt=attempt,
|
||||||
|
response=None,
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
raise timeout_error from None
|
||||||
|
except httpx.HTTPError:
|
||||||
|
transport_error = sanitized_error(
|
||||||
|
operation=operation,
|
||||||
|
kind=ProviderErrorKind.TRANSPORT,
|
||||||
|
provider_code="transport_error",
|
||||||
|
retryable=True,
|
||||||
|
)
|
||||||
|
if not emitted and await self._maybe_retry(
|
||||||
|
transport_error,
|
||||||
|
attempt=attempt,
|
||||||
|
response=None,
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
raise transport_error from None
|
||||||
|
|
||||||
|
raise AssertionError("bounded chat retry loop exhausted unexpectedly")
|
||||||
|
|
||||||
|
def _validate_messages(
|
||||||
|
self,
|
||||||
|
messages: object,
|
||||||
|
*,
|
||||||
|
max_tokens: int,
|
||||||
|
operation: str,
|
||||||
|
) -> tuple[ChatMessage, ...]:
|
||||||
|
if isinstance(messages, (str, bytes)) or not isinstance(messages, Sequence):
|
||||||
|
raise invalid_request(operation, "invalid_message_collection")
|
||||||
|
validated = tuple(messages)
|
||||||
|
if not validated:
|
||||||
|
raise invalid_request(operation, "empty_messages")
|
||||||
|
if isinstance(max_tokens, bool) or not isinstance(max_tokens, int) or max_tokens <= 0:
|
||||||
|
raise invalid_request(operation, "invalid_max_tokens")
|
||||||
|
for message in validated:
|
||||||
|
if (
|
||||||
|
not isinstance(message, ChatMessage)
|
||||||
|
or message.role not in _ALLOWED_ROLES
|
||||||
|
or not isinstance(message.content, str)
|
||||||
|
or not message.content
|
||||||
|
):
|
||||||
|
raise invalid_request(operation, "invalid_message")
|
||||||
|
return validated
|
||||||
|
|
||||||
|
def _payload(
|
||||||
|
self,
|
||||||
|
messages: tuple[ChatMessage, ...],
|
||||||
|
*,
|
||||||
|
max_tokens: int,
|
||||||
|
stream: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"model": self._model,
|
||||||
|
"messages": [
|
||||||
|
{"role": message.role, "content": message.content} for message in messages
|
||||||
|
],
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
"stream": stream,
|
||||||
|
"enable_thinking": False,
|
||||||
|
"enable_search": False,
|
||||||
|
}
|
||||||
|
if stream:
|
||||||
|
payload["stream_options"] = {"include_usage": True}
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def _parse_completion(
|
||||||
|
self,
|
||||||
|
body: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
operation: str,
|
||||||
|
) -> tuple[str, str | None]:
|
||||||
|
choices = body.get("choices")
|
||||||
|
if not isinstance(choices, list) or len(choices) != 1:
|
||||||
|
raise invalid_response(operation, "invalid_choices")
|
||||||
|
choice = choices[0]
|
||||||
|
if not isinstance(choice, Mapping):
|
||||||
|
raise invalid_response(operation, "invalid_choice")
|
||||||
|
message = choice.get("message")
|
||||||
|
if not isinstance(message, Mapping):
|
||||||
|
raise invalid_response(operation, "invalid_message")
|
||||||
|
content = message.get("content")
|
||||||
|
if not isinstance(content, str):
|
||||||
|
raise invalid_response(operation, "invalid_content")
|
||||||
|
finish_reason = choice.get("finish_reason")
|
||||||
|
if finish_reason is not None and not isinstance(finish_reason, str):
|
||||||
|
raise invalid_response(operation, "invalid_finish_reason")
|
||||||
|
return content, finish_reason
|
||||||
|
|
||||||
|
def _decode_stream_event(
|
||||||
|
self,
|
||||||
|
raw_data: str,
|
||||||
|
*,
|
||||||
|
operation: str,
|
||||||
|
) -> Mapping[str, Any]:
|
||||||
|
try:
|
||||||
|
decoded = json.loads(raw_data)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise invalid_response(operation, "invalid_stream_json") from None
|
||||||
|
if not isinstance(decoded, Mapping):
|
||||||
|
raise invalid_response(operation, "invalid_stream_object")
|
||||||
|
return decoded
|
||||||
|
|
||||||
|
def _parse_stream_event(
|
||||||
|
self,
|
||||||
|
body: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
operation: str,
|
||||||
|
sensitive_values: tuple[str, ...],
|
||||||
|
elapsed_ms: float,
|
||||||
|
) -> ChatStreamEvent:
|
||||||
|
choices = body.get("choices")
|
||||||
|
delta = ""
|
||||||
|
finish_reason: str | None = None
|
||||||
|
if choices not in (None, []):
|
||||||
|
if not isinstance(choices, list) or len(choices) != 1:
|
||||||
|
raise invalid_response(operation, "invalid_stream_choices")
|
||||||
|
choice = choices[0]
|
||||||
|
if not isinstance(choice, Mapping):
|
||||||
|
raise invalid_response(operation, "invalid_stream_choice")
|
||||||
|
raw_delta = choice.get("delta")
|
||||||
|
if not isinstance(raw_delta, Mapping):
|
||||||
|
raise invalid_response(operation, "invalid_stream_delta")
|
||||||
|
content = raw_delta.get("content", "")
|
||||||
|
if not isinstance(content, str):
|
||||||
|
raise invalid_response(operation, "invalid_stream_content")
|
||||||
|
delta = content
|
||||||
|
raw_finish_reason = choice.get("finish_reason")
|
||||||
|
if raw_finish_reason is not None and not isinstance(raw_finish_reason, str):
|
||||||
|
raise invalid_response(operation, "invalid_stream_finish_reason")
|
||||||
|
finish_reason = raw_finish_reason
|
||||||
|
|
||||||
|
usage = parse_usage(body.get("usage"))
|
||||||
|
if choices in (None, []) and usage == ProviderUsage():
|
||||||
|
raise invalid_response(operation, "empty_stream_event")
|
||||||
|
return ChatStreamEvent(
|
||||||
|
delta=delta,
|
||||||
|
finish_reason=finish_reason,
|
||||||
|
model=response_model(
|
||||||
|
body,
|
||||||
|
self._model,
|
||||||
|
sensitive_values=(*sensitive_values, self._api_key),
|
||||||
|
),
|
||||||
|
request_id=extract_request_id(
|
||||||
|
body,
|
||||||
|
sensitive_values=(*sensitive_values, self._api_key),
|
||||||
|
),
|
||||||
|
usage=usage,
|
||||||
|
elapsed_ms=elapsed_ms,
|
||||||
|
)
|
||||||
177
backend/app/adapters/bailian/embedding.py
Normal file
177
backend/app/adapters/bailian/embedding.py
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
"""Alibaba Cloud Model Studio OpenAI-compatible embedding adapter."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.adapters.bailian._base import (
|
||||||
|
BailianHttpAdapter,
|
||||||
|
conservative_utf8_token_count,
|
||||||
|
count_tokens,
|
||||||
|
extract_request_id,
|
||||||
|
invalid_request,
|
||||||
|
invalid_response,
|
||||||
|
parse_usage,
|
||||||
|
response_model,
|
||||||
|
)
|
||||||
|
from app.ports.model_providers import EmbeddingResult, TokenCounter
|
||||||
|
|
||||||
|
EMBEDDING_MAX_BATCH_SIZE = 10
|
||||||
|
EMBEDDING_MAX_TOKENS_PER_TEXT = 8_192
|
||||||
|
EMBEDDING_MAX_TOKENS_PER_REQUEST = 33_000
|
||||||
|
EMBEDDING_DIMENSIONS = 1_024
|
||||||
|
|
||||||
|
|
||||||
|
class BailianEmbeddingAdapter(BailianHttpAdapter):
|
||||||
|
"""Call ``compatible-mode/v1/embeddings`` with strict input/output checks."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
api_key: str,
|
||||||
|
base_url: str,
|
||||||
|
model: str = "text-embedding-v4",
|
||||||
|
dimensions: int = EMBEDDING_DIMENSIONS,
|
||||||
|
token_counter: TokenCounter = conservative_utf8_token_count,
|
||||||
|
http_client: httpx.AsyncClient | None = None,
|
||||||
|
timeout_seconds: float = 30.0,
|
||||||
|
max_retries: int = 0,
|
||||||
|
retry_base_seconds: float = 0.5,
|
||||||
|
) -> None:
|
||||||
|
if not model or model != model.strip():
|
||||||
|
raise invalid_request("embedding.configuration", "invalid_model")
|
||||||
|
if isinstance(dimensions, bool) or dimensions != EMBEDDING_DIMENSIONS:
|
||||||
|
raise invalid_request("embedding.configuration", "unsupported_dimensions")
|
||||||
|
if not callable(token_counter):
|
||||||
|
raise invalid_request("embedding.configuration", "invalid_token_counter")
|
||||||
|
|
||||||
|
super().__init__(
|
||||||
|
api_key=api_key,
|
||||||
|
base_url=base_url,
|
||||||
|
expected_path="/compatible-mode/v1",
|
||||||
|
http_client=http_client,
|
||||||
|
timeout_seconds=timeout_seconds,
|
||||||
|
max_retries=max_retries,
|
||||||
|
retry_base_seconds=retry_base_seconds,
|
||||||
|
)
|
||||||
|
self._model = model
|
||||||
|
self._dimensions = dimensions
|
||||||
|
self._token_counter = token_counter
|
||||||
|
|
||||||
|
async def embed_documents(self, texts: Sequence[str]) -> EmbeddingResult:
|
||||||
|
operation = "embedding.create"
|
||||||
|
validated_texts = self._validate_texts(texts, operation=operation)
|
||||||
|
payload = {
|
||||||
|
"model": self._model,
|
||||||
|
"input": list(validated_texts),
|
||||||
|
"dimensions": self._dimensions,
|
||||||
|
"encoding_format": "float",
|
||||||
|
}
|
||||||
|
response = await self._post_json(
|
||||||
|
operation=operation,
|
||||||
|
path="embeddings",
|
||||||
|
payload=payload,
|
||||||
|
sensitive_values=validated_texts,
|
||||||
|
)
|
||||||
|
vectors = self._parse_vectors(
|
||||||
|
response.body,
|
||||||
|
expected_count=len(validated_texts),
|
||||||
|
operation=operation,
|
||||||
|
)
|
||||||
|
return EmbeddingResult(
|
||||||
|
vectors=vectors,
|
||||||
|
model=response_model(
|
||||||
|
response.body,
|
||||||
|
self._model,
|
||||||
|
sensitive_values=(*validated_texts, self._api_key),
|
||||||
|
),
|
||||||
|
request_id=extract_request_id(
|
||||||
|
response.body,
|
||||||
|
sensitive_values=(*validated_texts, self._api_key),
|
||||||
|
),
|
||||||
|
usage=parse_usage(response.body.get("usage")),
|
||||||
|
elapsed_ms=response.elapsed_ms,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def embed_query(self, text: str) -> EmbeddingResult:
|
||||||
|
return await self.embed_documents((text,))
|
||||||
|
|
||||||
|
def _validate_texts(
|
||||||
|
self,
|
||||||
|
texts: Sequence[str],
|
||||||
|
*,
|
||||||
|
operation: str,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
if isinstance(texts, (str, bytes)) or not isinstance(texts, Sequence):
|
||||||
|
raise invalid_request(operation, "invalid_input_collection")
|
||||||
|
validated = tuple(texts)
|
||||||
|
if not validated:
|
||||||
|
raise invalid_request(operation, "empty_input")
|
||||||
|
if len(validated) > EMBEDDING_MAX_BATCH_SIZE:
|
||||||
|
raise invalid_request(operation, "batch_size_exceeded")
|
||||||
|
|
||||||
|
total_tokens = 0
|
||||||
|
for text in validated:
|
||||||
|
if not isinstance(text, str) or not text:
|
||||||
|
raise invalid_request(operation, "invalid_input_text")
|
||||||
|
token_count = count_tokens(
|
||||||
|
self._token_counter,
|
||||||
|
text,
|
||||||
|
operation=operation,
|
||||||
|
)
|
||||||
|
if token_count > EMBEDDING_MAX_TOKENS_PER_TEXT:
|
||||||
|
raise invalid_request(operation, "text_token_limit_exceeded")
|
||||||
|
total_tokens += token_count
|
||||||
|
if total_tokens > EMBEDDING_MAX_TOKENS_PER_REQUEST:
|
||||||
|
raise invalid_request(operation, "request_token_limit_exceeded")
|
||||||
|
return validated
|
||||||
|
|
||||||
|
def _parse_vectors(
|
||||||
|
self,
|
||||||
|
body: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
expected_count: int,
|
||||||
|
operation: str,
|
||||||
|
) -> tuple[tuple[float, ...], ...]:
|
||||||
|
data = body.get("data")
|
||||||
|
if not isinstance(data, list) or len(data) != expected_count:
|
||||||
|
raise invalid_response(operation, "invalid_embedding_count")
|
||||||
|
|
||||||
|
by_index: list[tuple[float, ...] | None] = [None] * expected_count
|
||||||
|
for item in data:
|
||||||
|
if not isinstance(item, Mapping):
|
||||||
|
raise invalid_response(operation, "invalid_embedding_item")
|
||||||
|
index = item.get("index")
|
||||||
|
if (
|
||||||
|
isinstance(index, bool)
|
||||||
|
or not isinstance(index, int)
|
||||||
|
or not 0 <= index < expected_count
|
||||||
|
or by_index[index] is not None
|
||||||
|
):
|
||||||
|
raise invalid_response(operation, "invalid_embedding_index")
|
||||||
|
|
||||||
|
raw_vector = item.get("embedding")
|
||||||
|
if not isinstance(raw_vector, list) or len(raw_vector) != self._dimensions:
|
||||||
|
raise invalid_response(operation, "invalid_embedding_dimensions")
|
||||||
|
|
||||||
|
vector: list[float] = []
|
||||||
|
for component in raw_vector:
|
||||||
|
if isinstance(component, bool) or not isinstance(component, (int, float)):
|
||||||
|
raise invalid_response(operation, "invalid_embedding_component")
|
||||||
|
normalized = float(component)
|
||||||
|
if not math.isfinite(normalized):
|
||||||
|
raise invalid_response(operation, "invalid_embedding_component")
|
||||||
|
vector.append(normalized)
|
||||||
|
|
||||||
|
norm = math.hypot(*vector)
|
||||||
|
if not math.isfinite(norm) or norm <= 0:
|
||||||
|
raise invalid_response(operation, "invalid_embedding_norm")
|
||||||
|
by_index[index] = tuple(vector)
|
||||||
|
|
||||||
|
if any(vector is None for vector in by_index):
|
||||||
|
raise invalid_response(operation, "missing_embedding_index")
|
||||||
|
return tuple(vector for vector in by_index if vector is not None)
|
||||||
208
backend/app/adapters/bailian/rerank.py
Normal file
208
backend/app/adapters/bailian/rerank.py
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
"""Alibaba Cloud Model Studio compatible rerank adapter."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.adapters.bailian._base import (
|
||||||
|
BailianHttpAdapter,
|
||||||
|
conservative_utf8_token_count,
|
||||||
|
count_tokens,
|
||||||
|
extract_request_id,
|
||||||
|
invalid_request,
|
||||||
|
invalid_response,
|
||||||
|
parse_usage,
|
||||||
|
response_model,
|
||||||
|
)
|
||||||
|
from app.ports.model_providers import RankedItem, RerankResult, TokenCounter
|
||||||
|
|
||||||
|
RERANK_MAX_DOCUMENTS = 500
|
||||||
|
RERANK_MAX_TOKENS_PER_TEXT = 4_000
|
||||||
|
RERANK_MAX_TOKENS_PER_REQUEST = 120_000
|
||||||
|
|
||||||
|
|
||||||
|
class BailianRerankerAdapter(BailianHttpAdapter):
|
||||||
|
"""Call ``compatible-api/v1/reranks`` and map indices locally."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
api_key: str,
|
||||||
|
base_url: str,
|
||||||
|
model: str = "qwen3-rerank",
|
||||||
|
token_counter: TokenCounter = conservative_utf8_token_count,
|
||||||
|
http_client: httpx.AsyncClient | None = None,
|
||||||
|
timeout_seconds: float = 30.0,
|
||||||
|
max_retries: int = 0,
|
||||||
|
retry_base_seconds: float = 0.5,
|
||||||
|
) -> None:
|
||||||
|
if not model or model != model.strip():
|
||||||
|
raise invalid_request("rerank.configuration", "invalid_model")
|
||||||
|
if not callable(token_counter):
|
||||||
|
raise invalid_request("rerank.configuration", "invalid_token_counter")
|
||||||
|
|
||||||
|
super().__init__(
|
||||||
|
api_key=api_key,
|
||||||
|
base_url=base_url,
|
||||||
|
expected_path="/compatible-api/v1",
|
||||||
|
http_client=http_client,
|
||||||
|
timeout_seconds=timeout_seconds,
|
||||||
|
max_retries=max_retries,
|
||||||
|
retry_base_seconds=retry_base_seconds,
|
||||||
|
)
|
||||||
|
self._model = model
|
||||||
|
self._token_counter = token_counter
|
||||||
|
|
||||||
|
async def rerank(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
documents: Sequence[str],
|
||||||
|
*,
|
||||||
|
top_n: int,
|
||||||
|
instruct: str | None = None,
|
||||||
|
) -> RerankResult:
|
||||||
|
operation = "rerank.create"
|
||||||
|
validated_documents = self._validate_request(
|
||||||
|
query=query,
|
||||||
|
documents=documents,
|
||||||
|
top_n=top_n,
|
||||||
|
instruct=instruct,
|
||||||
|
operation=operation,
|
||||||
|
)
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"model": self._model,
|
||||||
|
"query": query,
|
||||||
|
"documents": list(validated_documents),
|
||||||
|
"top_n": top_n,
|
||||||
|
}
|
||||||
|
if instruct is not None:
|
||||||
|
payload["instruct"] = instruct
|
||||||
|
|
||||||
|
sensitive_values = (
|
||||||
|
query,
|
||||||
|
*validated_documents,
|
||||||
|
*((instruct,) if instruct is not None else ()),
|
||||||
|
)
|
||||||
|
response = await self._post_json(
|
||||||
|
operation=operation,
|
||||||
|
path="reranks",
|
||||||
|
payload=payload,
|
||||||
|
sensitive_values=sensitive_values,
|
||||||
|
)
|
||||||
|
items = self._parse_results(
|
||||||
|
response.body,
|
||||||
|
documents=validated_documents,
|
||||||
|
top_n=top_n,
|
||||||
|
operation=operation,
|
||||||
|
)
|
||||||
|
return RerankResult(
|
||||||
|
items=items,
|
||||||
|
model=response_model(
|
||||||
|
response.body,
|
||||||
|
self._model,
|
||||||
|
sensitive_values=(*sensitive_values, self._api_key),
|
||||||
|
),
|
||||||
|
request_id=extract_request_id(
|
||||||
|
response.body,
|
||||||
|
sensitive_values=(*sensitive_values, self._api_key),
|
||||||
|
),
|
||||||
|
usage=parse_usage(response.body.get("usage")),
|
||||||
|
elapsed_ms=response.elapsed_ms,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _validate_request(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
query: str,
|
||||||
|
documents: Sequence[str],
|
||||||
|
top_n: int,
|
||||||
|
instruct: str | None,
|
||||||
|
operation: str,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
if not isinstance(query, str) or not query:
|
||||||
|
raise invalid_request(operation, "invalid_query")
|
||||||
|
if isinstance(documents, (str, bytes)) or not isinstance(documents, Sequence):
|
||||||
|
raise invalid_request(operation, "invalid_document_collection")
|
||||||
|
validated_documents = tuple(documents)
|
||||||
|
if not validated_documents:
|
||||||
|
raise invalid_request(operation, "empty_documents")
|
||||||
|
if len(validated_documents) > RERANK_MAX_DOCUMENTS:
|
||||||
|
raise invalid_request(operation, "document_count_exceeded")
|
||||||
|
if isinstance(top_n, bool) or not isinstance(top_n, int) or top_n <= 0:
|
||||||
|
raise invalid_request(operation, "invalid_top_n")
|
||||||
|
if instruct is not None and (not isinstance(instruct, str) or not instruct):
|
||||||
|
raise invalid_request(operation, "invalid_instruct")
|
||||||
|
|
||||||
|
query_tokens = count_tokens(
|
||||||
|
self._token_counter,
|
||||||
|
query,
|
||||||
|
operation=operation,
|
||||||
|
)
|
||||||
|
if query_tokens > RERANK_MAX_TOKENS_PER_TEXT:
|
||||||
|
raise invalid_request(operation, "query_token_limit_exceeded")
|
||||||
|
|
||||||
|
document_tokens_total = 0
|
||||||
|
for document in validated_documents:
|
||||||
|
if not isinstance(document, str) or not document:
|
||||||
|
raise invalid_request(operation, "invalid_document")
|
||||||
|
document_tokens = count_tokens(
|
||||||
|
self._token_counter,
|
||||||
|
document,
|
||||||
|
operation=operation,
|
||||||
|
)
|
||||||
|
if document_tokens > RERANK_MAX_TOKENS_PER_TEXT:
|
||||||
|
raise invalid_request(operation, "document_token_limit_exceeded")
|
||||||
|
document_tokens_total += document_tokens
|
||||||
|
|
||||||
|
request_tokens = query_tokens * len(validated_documents) + document_tokens_total
|
||||||
|
if request_tokens > RERANK_MAX_TOKENS_PER_REQUEST:
|
||||||
|
raise invalid_request(operation, "request_token_limit_exceeded")
|
||||||
|
return validated_documents
|
||||||
|
|
||||||
|
def _parse_results(
|
||||||
|
self,
|
||||||
|
body: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
documents: tuple[str, ...],
|
||||||
|
top_n: int,
|
||||||
|
operation: str,
|
||||||
|
) -> tuple[RankedItem, ...]:
|
||||||
|
results = body.get("results")
|
||||||
|
if not isinstance(results, list) or len(results) > min(top_n, len(documents)):
|
||||||
|
raise invalid_response(operation, "invalid_rerank_count")
|
||||||
|
|
||||||
|
seen_indices: set[int] = set()
|
||||||
|
parsed: list[RankedItem] = []
|
||||||
|
previous_score = math.inf
|
||||||
|
for item in results:
|
||||||
|
if not isinstance(item, Mapping):
|
||||||
|
raise invalid_response(operation, "invalid_rerank_item")
|
||||||
|
index = item.get("index")
|
||||||
|
if (
|
||||||
|
isinstance(index, bool)
|
||||||
|
or not isinstance(index, int)
|
||||||
|
or not 0 <= index < len(documents)
|
||||||
|
or index in seen_indices
|
||||||
|
):
|
||||||
|
raise invalid_response(operation, "invalid_rerank_index")
|
||||||
|
raw_score = item.get("relevance_score")
|
||||||
|
if isinstance(raw_score, bool) or not isinstance(raw_score, (int, float)):
|
||||||
|
raise invalid_response(operation, "invalid_rerank_score")
|
||||||
|
score = float(raw_score)
|
||||||
|
if not math.isfinite(score) or not 0 <= score <= 1 or score > previous_score:
|
||||||
|
raise invalid_response(operation, "invalid_rerank_score")
|
||||||
|
|
||||||
|
seen_indices.add(index)
|
||||||
|
previous_score = score
|
||||||
|
parsed.append(
|
||||||
|
RankedItem(
|
||||||
|
index=index,
|
||||||
|
relevance_score=score,
|
||||||
|
document=documents[index],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(parsed)
|
||||||
154
backend/app/adapters/fake.py
Normal file
154
backend/app/adapters/fake.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"""Deterministic local providers for tests and offline Docker verification only."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from app.ports.model_providers import (
|
||||||
|
EmbeddingResult,
|
||||||
|
ModelProviderError,
|
||||||
|
ProviderErrorKind,
|
||||||
|
ProviderUsage,
|
||||||
|
RankedItem,
|
||||||
|
RerankResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
_TOKEN_PATTERN = re.compile(r"[\u3400-\u9fff]+|[a-zA-Z0-9_.%+-]+")
|
||||||
|
|
||||||
|
|
||||||
|
def invalid_input(operation: str, code: str) -> ModelProviderError:
|
||||||
|
return ModelProviderError(
|
||||||
|
operation=operation,
|
||||||
|
kind=ProviderErrorKind.INVALID_REQUEST,
|
||||||
|
provider_code=code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def lexical_features(text: str) -> tuple[str, ...]:
|
||||||
|
"""Create stable character n-grams/words without pretending to be a tokenizer."""
|
||||||
|
|
||||||
|
features: list[str] = []
|
||||||
|
for token in _TOKEN_PATTERN.findall(text.lower()):
|
||||||
|
if "\u3400" <= token[0] <= "\u9fff":
|
||||||
|
features.extend(token)
|
||||||
|
features.extend(token[index : index + 2] for index in range(len(token) - 1))
|
||||||
|
else:
|
||||||
|
features.append(token)
|
||||||
|
return tuple(features)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeEmbeddingProvider:
|
||||||
|
"""Feature-hashing embedding used to validate plumbing without cloud calls."""
|
||||||
|
|
||||||
|
def __init__(self, dimension: int = 1024) -> None:
|
||||||
|
if dimension < 1:
|
||||||
|
raise ValueError("dimension must be positive")
|
||||||
|
self.dimension = dimension
|
||||||
|
|
||||||
|
def _vector(self, text: str) -> tuple[float, ...]:
|
||||||
|
vector = [0.0] * self.dimension
|
||||||
|
for feature in lexical_features(text):
|
||||||
|
digest = hashlib.sha256(feature.encode("utf-8")).digest()
|
||||||
|
index = int.from_bytes(digest[:4], "big") % self.dimension
|
||||||
|
sign = 1.0 if digest[4] & 1 else -1.0
|
||||||
|
vector[index] += sign
|
||||||
|
norm = math.sqrt(sum(value * value for value in vector))
|
||||||
|
if norm == 0:
|
||||||
|
vector[0] = 1.0
|
||||||
|
norm = 1.0
|
||||||
|
return tuple(value / norm for value in vector)
|
||||||
|
|
||||||
|
async def _embed(self, texts: Sequence[str]) -> EmbeddingResult:
|
||||||
|
if isinstance(texts, (str, bytes)) or not isinstance(texts, Sequence):
|
||||||
|
raise invalid_input("fake.embedding", "invalid_input_collection")
|
||||||
|
if not texts:
|
||||||
|
raise invalid_input("fake.embedding", "empty_input")
|
||||||
|
if len(texts) > 10:
|
||||||
|
raise invalid_input("fake.embedding", "batch_size_exceeded")
|
||||||
|
token_counts = []
|
||||||
|
for text in texts:
|
||||||
|
if not isinstance(text, str) or not text:
|
||||||
|
raise invalid_input("fake.embedding", "invalid_input_text")
|
||||||
|
token_count = len(text.encode("utf-8"))
|
||||||
|
if token_count > 8_192:
|
||||||
|
raise invalid_input("fake.embedding", "text_token_limit_exceeded")
|
||||||
|
token_counts.append(token_count)
|
||||||
|
if sum(token_counts) > 33_000:
|
||||||
|
raise invalid_input("fake.embedding", "request_token_limit_exceeded")
|
||||||
|
|
||||||
|
started = time.perf_counter()
|
||||||
|
vectors = tuple(self._vector(text) for text in texts)
|
||||||
|
return EmbeddingResult(
|
||||||
|
vectors=vectors,
|
||||||
|
model="fake-feature-hash-v1",
|
||||||
|
request_id=None,
|
||||||
|
usage=ProviderUsage(input_tokens=sum(len(lexical_features(text)) for text in texts)),
|
||||||
|
elapsed_ms=(time.perf_counter() - started) * 1000,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def embed_documents(self, texts: Sequence[str]) -> EmbeddingResult:
|
||||||
|
return await self._embed(texts)
|
||||||
|
|
||||||
|
async def embed_query(self, text: str) -> EmbeddingResult:
|
||||||
|
return await self._embed([text])
|
||||||
|
|
||||||
|
|
||||||
|
class FakeReranker:
|
||||||
|
"""Lexical-overlap reranker for deterministic offline flow tests."""
|
||||||
|
|
||||||
|
async def rerank(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
documents: Sequence[str],
|
||||||
|
*,
|
||||||
|
top_n: int,
|
||||||
|
instruct: str | None = None,
|
||||||
|
) -> RerankResult:
|
||||||
|
del instruct
|
||||||
|
if not isinstance(query, str) or not query:
|
||||||
|
raise invalid_input("fake.rerank", "invalid_query")
|
||||||
|
if isinstance(documents, (str, bytes)) or not isinstance(documents, Sequence):
|
||||||
|
raise invalid_input("fake.rerank", "invalid_document_collection")
|
||||||
|
if not documents:
|
||||||
|
raise invalid_input("fake.rerank", "empty_documents")
|
||||||
|
if len(documents) > 500:
|
||||||
|
raise invalid_input("fake.rerank", "document_count_exceeded")
|
||||||
|
if isinstance(top_n, bool) or not isinstance(top_n, int) or top_n <= 0:
|
||||||
|
raise invalid_input("fake.rerank", "invalid_top_n")
|
||||||
|
query_tokens = len(query.encode("utf-8"))
|
||||||
|
if query_tokens > 4_000:
|
||||||
|
raise invalid_input("fake.rerank", "query_token_limit_exceeded")
|
||||||
|
document_tokens = []
|
||||||
|
for document in documents:
|
||||||
|
if not isinstance(document, str) or not document:
|
||||||
|
raise invalid_input("fake.rerank", "invalid_document")
|
||||||
|
count = len(document.encode("utf-8"))
|
||||||
|
if count > 4_000:
|
||||||
|
raise invalid_input("fake.rerank", "document_token_limit_exceeded")
|
||||||
|
document_tokens.append(count)
|
||||||
|
if query_tokens * len(documents) + sum(document_tokens) > 120_000:
|
||||||
|
raise invalid_input("fake.rerank", "request_token_limit_exceeded")
|
||||||
|
|
||||||
|
started = time.perf_counter()
|
||||||
|
query_features = set(lexical_features(query))
|
||||||
|
ranked: list[RankedItem] = []
|
||||||
|
for index, document in enumerate(documents):
|
||||||
|
document_features = set(lexical_features(document))
|
||||||
|
union = query_features | document_features
|
||||||
|
score = len(query_features & document_features) / len(union) if union else 0.0
|
||||||
|
ranked.append(RankedItem(index=index, relevance_score=score, document=document))
|
||||||
|
ranked.sort(key=lambda item: (-item.relevance_score, item.index))
|
||||||
|
return RerankResult(
|
||||||
|
items=tuple(ranked[:top_n]),
|
||||||
|
model="fake-lexical-rerank-v1",
|
||||||
|
request_id=None,
|
||||||
|
usage=ProviderUsage(
|
||||||
|
input_tokens=len(query_features)
|
||||||
|
+ sum(len(lexical_features(document)) for document in documents)
|
||||||
|
),
|
||||||
|
elapsed_ms=(time.perf_counter() - started) * 1000,
|
||||||
|
)
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
1
backend/app/core/__init__.py
Normal file
1
backend/app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Configuration and security primitives."""
|
||||||
128
backend/app/core/config.py
Normal file
128
backend/app/core/config.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""Typed runtime configuration loaded from non-secret environment values."""
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal, Self
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from pydantic import Field, field_validator, model_validator
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
from sqlalchemy import URL
|
||||||
|
|
||||||
|
from app.core.secrets import read_secret_file
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Application settings; secret values stay in mounted files."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
case_sensitive=False,
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
app_env: Literal["development", "test", "production"] = "development"
|
||||||
|
app_name: str = "geological-rag"
|
||||||
|
app_secret_key_file: Path = Path("/run/secrets/app_secret_key")
|
||||||
|
|
||||||
|
postgres_host: str = "db"
|
||||||
|
postgres_port: int = Field(default=5432, ge=1, le=65535)
|
||||||
|
postgres_db: str = "geological_rag"
|
||||||
|
postgres_user: str = "geological_rag_app"
|
||||||
|
postgres_password_file: Path = Path("/run/secrets/postgres_app_password")
|
||||||
|
|
||||||
|
upload_root: Path = Path("/data/uploads")
|
||||||
|
max_upload_mb: int = Field(default=100, ge=1, le=2048)
|
||||||
|
|
||||||
|
bailian_openai_base_url: str = (
|
||||||
|
"https://<workspace-id>.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"
|
||||||
|
)
|
||||||
|
bailian_native_base_url: str = "https://<workspace-id>.cn-beijing.maas.aliyuncs.com/api/v1"
|
||||||
|
bailian_rerank_base_url: str = (
|
||||||
|
"https://<workspace-id>.cn-beijing.maas.aliyuncs.com/compatible-api/v1"
|
||||||
|
)
|
||||||
|
dashscope_api_key_file: Path = Path("/run/secrets/bailian_api_key")
|
||||||
|
|
||||||
|
embedding_model: str = "text-embedding-v4"
|
||||||
|
embedding_dimension: Literal[1024] = 1024
|
||||||
|
rerank_model: str = "qwen3-rerank"
|
||||||
|
llm_model: str = "deepseek-v4-flash"
|
||||||
|
|
||||||
|
chunk_target_tokens: int = Field(default=512, ge=120, le=800)
|
||||||
|
chunk_max_tokens: int = Field(default=800, ge=120, le=4000)
|
||||||
|
chunk_overlap_tokens: int = Field(default=64, ge=0, le=256)
|
||||||
|
vector_top_k: int = Field(default=50, ge=1, le=500)
|
||||||
|
rerank_top_n: int = Field(default=10, ge=1, le=500)
|
||||||
|
context_top_n: int = Field(default=8, ge=1, le=50)
|
||||||
|
max_context_tokens: int = Field(default=10_000, ge=1, le=100_000)
|
||||||
|
|
||||||
|
model_timeout_seconds: float = Field(default=90, gt=0, le=600)
|
||||||
|
model_max_retries: int = Field(default=3, ge=0, le=10)
|
||||||
|
model_max_concurrency: int = Field(default=4, ge=1, le=100)
|
||||||
|
worker_capabilities: str = "document_parse,embedding,rerank,evaluation"
|
||||||
|
|
||||||
|
@field_validator(
|
||||||
|
"bailian_openai_base_url",
|
||||||
|
"bailian_native_base_url",
|
||||||
|
"bailian_rerank_base_url",
|
||||||
|
)
|
||||||
|
@classmethod
|
||||||
|
def normalize_base_url(cls, value: str) -> str:
|
||||||
|
return value.rstrip("/")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_rag_limits(self) -> Self:
|
||||||
|
if self.chunk_overlap_tokens >= self.chunk_target_tokens:
|
||||||
|
raise ValueError("CHUNK_OVERLAP_TOKENS must be smaller than CHUNK_TARGET_TOKENS")
|
||||||
|
if self.chunk_target_tokens > self.chunk_max_tokens:
|
||||||
|
raise ValueError("CHUNK_TARGET_TOKENS must not exceed CHUNK_MAX_TOKENS")
|
||||||
|
if self.context_top_n > self.rerank_top_n:
|
||||||
|
raise ValueError("CONTEXT_TOP_N must not exceed RERANK_TOP_N")
|
||||||
|
if self.rerank_top_n > self.vector_top_k:
|
||||||
|
raise ValueError("RERANK_TOP_N must not exceed VECTOR_TOP_K")
|
||||||
|
return self
|
||||||
|
|
||||||
|
def bailian_api_key(self) -> str:
|
||||||
|
self.validate_live_bailian_endpoints()
|
||||||
|
return read_secret_file(self.dashscope_api_key_file)
|
||||||
|
|
||||||
|
def validate_live_bailian_endpoints(self) -> str:
|
||||||
|
endpoints = {
|
||||||
|
self.bailian_openai_base_url: "/compatible-mode/v1",
|
||||||
|
self.bailian_rerank_base_url: "/compatible-api/v1",
|
||||||
|
}
|
||||||
|
hosts: set[str] = set()
|
||||||
|
for endpoint, expected_path in endpoints.items():
|
||||||
|
parsed = urlsplit(endpoint)
|
||||||
|
host = parsed.hostname or ""
|
||||||
|
if (
|
||||||
|
parsed.scheme != "https"
|
||||||
|
or parsed.path.rstrip("/") != expected_path
|
||||||
|
or parsed.username is not None
|
||||||
|
or parsed.password is not None
|
||||||
|
or parsed.query
|
||||||
|
or parsed.fragment
|
||||||
|
or "<" in endpoint
|
||||||
|
or not host.endswith(".cn-beijing.maas.aliyuncs.com")
|
||||||
|
):
|
||||||
|
raise ValueError("Bailian endpoint is not an approved Beijing MaaS URL")
|
||||||
|
hosts.add(host)
|
||||||
|
if len(hosts) != 1:
|
||||||
|
raise ValueError("Bailian endpoints must use the same workspace host")
|
||||||
|
return hosts.pop()
|
||||||
|
|
||||||
|
def database_url(self) -> URL:
|
||||||
|
return URL.create(
|
||||||
|
drivername="postgresql+psycopg",
|
||||||
|
username=self.postgres_user,
|
||||||
|
password=read_secret_file(self.postgres_password_file),
|
||||||
|
host=self.postgres_host,
|
||||||
|
port=self.postgres_port,
|
||||||
|
database=self.postgres_db,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
24
backend/app/core/secrets.py
Normal file
24
backend/app/core/secrets.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""Secret-file helpers that never include secret values in errors or reprs."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class SecretFileError(RuntimeError):
|
||||||
|
"""Raised when a configured secret file cannot be read safely."""
|
||||||
|
|
||||||
|
|
||||||
|
def read_secret_file(path: Path) -> str:
|
||||||
|
"""Read one Docker/Kubernetes secret without exposing its content."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not path.is_file():
|
||||||
|
raise SecretFileError(f"Secret file is missing or not a file: {path}")
|
||||||
|
value = path.read_text(encoding="utf-8").strip()
|
||||||
|
except OSError as exc:
|
||||||
|
raise SecretFileError(f"Secret file cannot be read: {path}") from exc
|
||||||
|
|
||||||
|
if not value:
|
||||||
|
raise SecretFileError(f"Secret file is empty: {path}")
|
||||||
|
if "\n" in value or "\r" in value:
|
||||||
|
raise SecretFileError(f"Secret file must contain exactly one logical line: {path}")
|
||||||
|
return value
|
||||||
56
backend/app/main.py
Normal file
56
backend/app/main.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""Minimal FastAPI entrypoint; product endpoints are added in stage 2."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI, HTTPException, status
|
||||||
|
|
||||||
|
from app import __version__
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.secrets import SecretFileError
|
||||||
|
|
||||||
|
app = FastAPI(title="Geological RAG API", version=__version__)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/health/live", tags=["health"])
|
||||||
|
def live() -> dict[str, str]:
|
||||||
|
return {"status": "ok", "version": __version__}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/health/ready", tags=["health"])
|
||||||
|
def ready() -> dict[str, str]:
|
||||||
|
settings = get_settings()
|
||||||
|
try:
|
||||||
|
dsn = settings.database_url().set(drivername="postgresql")
|
||||||
|
with psycopg.connect(
|
||||||
|
dsn.render_as_string(hide_password=False),
|
||||||
|
connect_timeout=2,
|
||||||
|
) as connection:
|
||||||
|
connection.execute("SELECT 1")
|
||||||
|
except (OSError, SecretFileError, psycopg.Error) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="database unavailable",
|
||||||
|
) from exc
|
||||||
|
return {"status": "ready"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/meta", tags=["meta"])
|
||||||
|
def meta() -> dict[str, Any]:
|
||||||
|
settings = get_settings()
|
||||||
|
return {
|
||||||
|
"name": settings.app_name,
|
||||||
|
"environment": settings.app_env,
|
||||||
|
"version": __version__,
|
||||||
|
"models": {
|
||||||
|
"embedding": settings.embedding_model,
|
||||||
|
"rerank": settings.rerank_model,
|
||||||
|
"generation": settings.llm_model,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Container ingress is controlled by Compose; only the edge proxy publishes a port.
|
||||||
|
uvicorn.run("app.main:app", host="0.0.0.0", port=8000) # noqa: S104
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
17
backend/app/persistence/__init__.py
Normal file
17
backend/app/persistence/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"""Persistence-level contracts shared by workers and integration tests."""
|
||||||
|
|
||||||
|
from .job_queue_sql import (
|
||||||
|
CLAIM_JOB_SQL,
|
||||||
|
COMPLETE_JOB_SQL,
|
||||||
|
FAIL_OR_RETRY_JOB_SQL,
|
||||||
|
HEARTBEAT_JOB_SQL,
|
||||||
|
REAP_EXPIRED_JOBS_SQL,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CLAIM_JOB_SQL",
|
||||||
|
"COMPLETE_JOB_SQL",
|
||||||
|
"FAIL_OR_RETRY_JOB_SQL",
|
||||||
|
"HEARTBEAT_JOB_SQL",
|
||||||
|
"REAP_EXPIRED_JOBS_SQL",
|
||||||
|
]
|
||||||
129
backend/app/persistence/job_queue_sql.py
Normal file
129
backend/app/persistence/job_queue_sql.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
"""Fenced PostgreSQL job-queue statements.
|
||||||
|
|
||||||
|
Callers must execute each statement in a transaction and treat an empty
|
||||||
|
``RETURNING`` result as loss of the lease. No external model call should hold
|
||||||
|
the claim transaction open.
|
||||||
|
"""
|
||||||
|
|
||||||
|
CLAIM_JOB_SQL = """
|
||||||
|
WITH candidate AS (
|
||||||
|
SELECT job.id
|
||||||
|
FROM rag.background_jobs AS job
|
||||||
|
WHERE job.attempt < job.max_attempts
|
||||||
|
AND job.required_capability = ANY(CAST(:worker_capabilities AS text[]))
|
||||||
|
AND (
|
||||||
|
(job.status = 'QUEUED' AND job.run_after <= now())
|
||||||
|
OR (job.status = 'RUNNING' AND job.lease_until < now())
|
||||||
|
)
|
||||||
|
ORDER BY job.priority DESC, job.run_after, job.created_at
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
UPDATE rag.background_jobs AS job
|
||||||
|
SET status = 'RUNNING',
|
||||||
|
lease_owner = :worker_id,
|
||||||
|
lease_token = gen_random_uuid(),
|
||||||
|
lease_until = now() + make_interval(secs => CAST(:lease_seconds AS integer)),
|
||||||
|
attempt = job.attempt + 1,
|
||||||
|
updated_at = now(),
|
||||||
|
finished_at = NULL
|
||||||
|
FROM candidate
|
||||||
|
WHERE job.id = candidate.id
|
||||||
|
RETURNING job.*
|
||||||
|
"""
|
||||||
|
|
||||||
|
HEARTBEAT_JOB_SQL = """
|
||||||
|
UPDATE rag.background_jobs AS job
|
||||||
|
SET lease_until = now() + make_interval(secs => CAST(:lease_seconds AS integer)),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE job.id = :job_id
|
||||||
|
AND job.status = 'RUNNING'
|
||||||
|
AND job.lease_owner = :worker_id
|
||||||
|
AND job.lease_token = :lease_token
|
||||||
|
RETURNING job.id, job.lease_until
|
||||||
|
"""
|
||||||
|
|
||||||
|
COMPLETE_JOB_SQL = """
|
||||||
|
UPDATE rag.background_jobs AS job
|
||||||
|
SET status = 'SUCCEEDED',
|
||||||
|
progress = 100,
|
||||||
|
lease_owner = NULL,
|
||||||
|
lease_token = NULL,
|
||||||
|
lease_until = NULL,
|
||||||
|
finished_at = now(),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE job.id = :job_id
|
||||||
|
AND job.status = 'RUNNING'
|
||||||
|
AND job.lease_owner = :worker_id
|
||||||
|
AND job.lease_token = :lease_token
|
||||||
|
RETURNING job.*
|
||||||
|
"""
|
||||||
|
|
||||||
|
FAIL_OR_RETRY_JOB_SQL = """
|
||||||
|
UPDATE rag.background_jobs AS job
|
||||||
|
SET status = CASE
|
||||||
|
WHEN job.attempt < job.max_attempts THEN 'QUEUED'
|
||||||
|
ELSE 'FAILED'
|
||||||
|
END,
|
||||||
|
run_after = CASE
|
||||||
|
WHEN job.attempt < job.max_attempts
|
||||||
|
THEN now() + make_interval(
|
||||||
|
secs => GREATEST(CAST(:retry_delay_seconds AS integer), 0)
|
||||||
|
)
|
||||||
|
ELSE job.run_after
|
||||||
|
END,
|
||||||
|
last_error_code = :error_code,
|
||||||
|
last_error_message = left(:error_message, 2000),
|
||||||
|
lease_owner = NULL,
|
||||||
|
lease_token = NULL,
|
||||||
|
lease_until = NULL,
|
||||||
|
finished_at = CASE
|
||||||
|
WHEN job.attempt < job.max_attempts THEN NULL
|
||||||
|
ELSE now()
|
||||||
|
END,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE job.id = :job_id
|
||||||
|
AND job.status = 'RUNNING'
|
||||||
|
AND job.lease_owner = :worker_id
|
||||||
|
AND job.lease_token = :lease_token
|
||||||
|
RETURNING job.*
|
||||||
|
"""
|
||||||
|
|
||||||
|
REAP_EXPIRED_JOBS_SQL = """
|
||||||
|
WITH maintenance_lock AS (
|
||||||
|
SELECT pg_try_advisory_xact_lock(CAST(:lock_key AS bigint)) AS acquired
|
||||||
|
),
|
||||||
|
expired AS (
|
||||||
|
SELECT job.id
|
||||||
|
FROM rag.background_jobs AS job
|
||||||
|
CROSS JOIN maintenance_lock
|
||||||
|
WHERE maintenance_lock.acquired
|
||||||
|
AND job.status = 'RUNNING'
|
||||||
|
AND job.lease_until < now()
|
||||||
|
ORDER BY job.lease_until, job.created_at
|
||||||
|
FOR UPDATE OF job SKIP LOCKED
|
||||||
|
LIMIT CAST(:batch_size AS integer)
|
||||||
|
)
|
||||||
|
UPDATE rag.background_jobs AS job
|
||||||
|
SET status = CASE
|
||||||
|
WHEN job.attempt < job.max_attempts THEN 'QUEUED'
|
||||||
|
ELSE 'FAILED'
|
||||||
|
END,
|
||||||
|
run_after = CASE
|
||||||
|
WHEN job.attempt < job.max_attempts THEN now()
|
||||||
|
ELSE job.run_after
|
||||||
|
END,
|
||||||
|
last_error_code = 'WORKER_LEASE_EXPIRED',
|
||||||
|
last_error_message = 'Worker lease expired before a fenced terminal update.',
|
||||||
|
lease_owner = NULL,
|
||||||
|
lease_token = NULL,
|
||||||
|
lease_until = NULL,
|
||||||
|
finished_at = CASE
|
||||||
|
WHEN job.attempt < job.max_attempts THEN NULL
|
||||||
|
ELSE now()
|
||||||
|
END,
|
||||||
|
updated_at = now()
|
||||||
|
FROM expired
|
||||||
|
WHERE job.id = expired.id
|
||||||
|
RETURNING job.*
|
||||||
|
"""
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
151
backend/app/ports/model_providers.py
Normal file
151
backend/app/ports/model_providers.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""Dependency-inversion ports for external model providers.
|
||||||
|
|
||||||
|
The domain and service layers depend on these provider-neutral value objects and
|
||||||
|
protocols. Vendor SDK/HTTP response objects must not cross this boundary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator, Callable, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
TokenCounter = Callable[[str], int]
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderErrorKind(StrEnum):
|
||||||
|
"""Stable, provider-neutral error categories."""
|
||||||
|
|
||||||
|
INVALID_REQUEST = "invalid_request"
|
||||||
|
AUTHENTICATION = "authentication"
|
||||||
|
PERMISSION_DENIED = "permission_denied"
|
||||||
|
NOT_FOUND = "not_found"
|
||||||
|
RATE_LIMITED = "rate_limited"
|
||||||
|
TIMEOUT = "timeout"
|
||||||
|
TRANSPORT = "transport"
|
||||||
|
UPSTREAM = "upstream"
|
||||||
|
INVALID_RESPONSE = "invalid_response"
|
||||||
|
|
||||||
|
|
||||||
|
class ModelProviderError(RuntimeError):
|
||||||
|
"""Sanitized provider failure safe for structured application handling.
|
||||||
|
|
||||||
|
The exception intentionally never stores request payloads, response bodies,
|
||||||
|
HTTP request objects, headers, or credentials.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
operation: str,
|
||||||
|
kind: ProviderErrorKind,
|
||||||
|
status_code: int | None = None,
|
||||||
|
provider_code: str | None = None,
|
||||||
|
request_id: str | None = None,
|
||||||
|
retryable: bool = False,
|
||||||
|
) -> None:
|
||||||
|
self.operation = operation
|
||||||
|
self.kind = kind
|
||||||
|
self.status_code = status_code
|
||||||
|
self.provider_code = provider_code
|
||||||
|
self.request_id = request_id
|
||||||
|
self.retryable = retryable
|
||||||
|
|
||||||
|
details = [f"kind={kind.value}"]
|
||||||
|
if status_code is not None:
|
||||||
|
details.append(f"status={status_code}")
|
||||||
|
if provider_code is not None:
|
||||||
|
details.append(f"code={provider_code}")
|
||||||
|
super().__init__(f"model provider {operation} failed ({', '.join(details)})")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ProviderUsage:
|
||||||
|
input_tokens: int | None = None
|
||||||
|
output_tokens: int | None = None
|
||||||
|
total_tokens: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class EmbeddingResult:
|
||||||
|
vectors: tuple[tuple[float, ...], ...]
|
||||||
|
model: str
|
||||||
|
request_id: str | None
|
||||||
|
usage: ProviderUsage
|
||||||
|
elapsed_ms: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RankedItem:
|
||||||
|
index: int
|
||||||
|
relevance_score: float
|
||||||
|
document: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RerankResult:
|
||||||
|
items: tuple[RankedItem, ...]
|
||||||
|
model: str
|
||||||
|
request_id: str | None
|
||||||
|
usage: ProviderUsage
|
||||||
|
elapsed_ms: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ChatMessage:
|
||||||
|
role: str
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ChatCompletionResult:
|
||||||
|
content: str
|
||||||
|
finish_reason: str | None
|
||||||
|
model: str
|
||||||
|
request_id: str | None
|
||||||
|
usage: ProviderUsage
|
||||||
|
elapsed_ms: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ChatStreamEvent:
|
||||||
|
delta: str
|
||||||
|
finish_reason: str | None
|
||||||
|
model: str
|
||||||
|
request_id: str | None
|
||||||
|
usage: ProviderUsage
|
||||||
|
elapsed_ms: float
|
||||||
|
|
||||||
|
|
||||||
|
class EmbeddingProvider(Protocol):
|
||||||
|
async def embed_documents(self, texts: Sequence[str]) -> EmbeddingResult: ...
|
||||||
|
|
||||||
|
async def embed_query(self, text: str) -> EmbeddingResult: ...
|
||||||
|
|
||||||
|
|
||||||
|
class Reranker(Protocol):
|
||||||
|
async def rerank(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
documents: Sequence[str],
|
||||||
|
*,
|
||||||
|
top_n: int,
|
||||||
|
instruct: str | None = None,
|
||||||
|
) -> RerankResult: ...
|
||||||
|
|
||||||
|
|
||||||
|
class ChatProvider(Protocol):
|
||||||
|
async def complete(
|
||||||
|
self,
|
||||||
|
messages: Sequence[ChatMessage],
|
||||||
|
*,
|
||||||
|
max_tokens: int,
|
||||||
|
) -> ChatCompletionResult: ...
|
||||||
|
|
||||||
|
def stream(
|
||||||
|
self,
|
||||||
|
messages: Sequence[ChatMessage],
|
||||||
|
*,
|
||||||
|
max_tokens: int,
|
||||||
|
) -> AsyncIterator[ChatStreamEvent]: ...
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
1
backend/app/tools/__init__.py
Normal file
1
backend/app/tools/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Operational command entrypoints shipped in the backend image."""
|
||||||
190
backend/app/tools/provider_smoke.py
Normal file
190
backend/app/tools/provider_smoke.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
"""Minimal live probes for the three configured Bailian capabilities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.adapters.bailian import (
|
||||||
|
BailianChatAdapter,
|
||||||
|
BailianEmbeddingAdapter,
|
||||||
|
BailianRerankerAdapter,
|
||||||
|
)
|
||||||
|
from app.core.config import Settings
|
||||||
|
from app.core.secrets import SecretFileError
|
||||||
|
from app.ports.model_providers import ChatMessage, ModelProviderError
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ProbeResult:
|
||||||
|
capability: str
|
||||||
|
status: str
|
||||||
|
model: str | None = None
|
||||||
|
elapsed_ms: float | None = None
|
||||||
|
request_id: str | None = None
|
||||||
|
error_kind: str | None = None
|
||||||
|
status_code: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def probe_embedding(settings: Settings, api_key: str) -> ProbeResult:
|
||||||
|
adapter = BailianEmbeddingAdapter(
|
||||||
|
api_key=api_key,
|
||||||
|
base_url=settings.bailian_openai_base_url,
|
||||||
|
model=settings.embedding_model,
|
||||||
|
dimensions=settings.embedding_dimension,
|
||||||
|
timeout_seconds=settings.model_timeout_seconds,
|
||||||
|
max_retries=settings.model_max_retries,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = await adapter.embed_documents(["用于能力探测的虚构地质文本。"])
|
||||||
|
if len(result.vectors) != 1 or len(result.vectors[0]) != settings.embedding_dimension:
|
||||||
|
raise RuntimeError("embedding contract mismatch")
|
||||||
|
return ProbeResult(
|
||||||
|
capability="embedding",
|
||||||
|
status="ok",
|
||||||
|
model=result.model,
|
||||||
|
elapsed_ms=round(result.elapsed_ms, 2),
|
||||||
|
request_id=result.request_id,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await adapter.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def probe_rerank(settings: Settings, api_key: str) -> ProbeResult:
|
||||||
|
adapter = BailianRerankerAdapter(
|
||||||
|
api_key=api_key,
|
||||||
|
base_url=settings.bailian_rerank_base_url,
|
||||||
|
model=settings.rerank_model,
|
||||||
|
timeout_seconds=settings.model_timeout_seconds,
|
||||||
|
max_retries=settings.model_max_retries,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = await adapter.rerank(
|
||||||
|
"哪段文本提到了斑岩铜矿?",
|
||||||
|
["虚构斑岩铜矿具有钾化带。", "虚构煤层采用测井曲线对比。"],
|
||||||
|
top_n=1,
|
||||||
|
)
|
||||||
|
if len(result.items) != 1 or result.items[0].index not in (0, 1):
|
||||||
|
raise RuntimeError("rerank contract mismatch")
|
||||||
|
return ProbeResult(
|
||||||
|
capability="rerank",
|
||||||
|
status="ok",
|
||||||
|
model=result.model,
|
||||||
|
elapsed_ms=round(result.elapsed_ms, 2),
|
||||||
|
request_id=result.request_id,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await adapter.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def probe_chat(settings: Settings, api_key: str) -> ProbeResult:
|
||||||
|
adapter = BailianChatAdapter(
|
||||||
|
api_key=api_key,
|
||||||
|
base_url=settings.bailian_openai_base_url,
|
||||||
|
model=settings.llm_model,
|
||||||
|
timeout_seconds=settings.model_timeout_seconds,
|
||||||
|
max_retries=settings.model_max_retries,
|
||||||
|
)
|
||||||
|
model: str | None = None
|
||||||
|
request_id: str | None = None
|
||||||
|
elapsed_ms = 0.0
|
||||||
|
content_seen = False
|
||||||
|
try:
|
||||||
|
async for event in adapter.stream(
|
||||||
|
[ChatMessage(role="user", content="只回复:能力正常")],
|
||||||
|
max_tokens=16,
|
||||||
|
):
|
||||||
|
model = event.model
|
||||||
|
request_id = event.request_id or request_id
|
||||||
|
elapsed_ms = max(elapsed_ms, event.elapsed_ms)
|
||||||
|
content_seen = content_seen or bool(event.delta)
|
||||||
|
if not content_seen:
|
||||||
|
raise RuntimeError("chat stream contained no text")
|
||||||
|
return ProbeResult(
|
||||||
|
capability="chat",
|
||||||
|
status="ok",
|
||||||
|
model=model,
|
||||||
|
elapsed_ms=round(elapsed_ms, 2),
|
||||||
|
request_id=request_id,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await adapter.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
def failed_probe(capability: str, error: BaseException) -> ProbeResult:
|
||||||
|
if isinstance(error, ModelProviderError):
|
||||||
|
return ProbeResult(
|
||||||
|
capability=capability,
|
||||||
|
status="failed",
|
||||||
|
request_id=error.request_id,
|
||||||
|
error_kind=error.kind.value,
|
||||||
|
status_code=error.status_code,
|
||||||
|
)
|
||||||
|
return ProbeResult(
|
||||||
|
capability=capability,
|
||||||
|
status="failed",
|
||||||
|
error_kind="internal_contract_error",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_probe(
|
||||||
|
capability: str,
|
||||||
|
operation: Callable[[Settings, str], Awaitable[ProbeResult]],
|
||||||
|
settings: Settings,
|
||||||
|
api_key: str,
|
||||||
|
) -> ProbeResult:
|
||||||
|
try:
|
||||||
|
return await operation(settings, api_key)
|
||||||
|
except Exception as exc: # The output is deliberately reduced to a safe category.
|
||||||
|
return failed_probe(capability, exc)
|
||||||
|
|
||||||
|
|
||||||
|
def write_json_line(payload: dict[str, Any]) -> None:
|
||||||
|
sys.stdout.write(json.dumps(payload, ensure_ascii=False, sort_keys=True) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
async def async_main() -> int:
|
||||||
|
try:
|
||||||
|
settings = Settings()
|
||||||
|
if any(
|
||||||
|
"<workspace-id>" in url
|
||||||
|
for url in (
|
||||||
|
settings.bailian_openai_base_url,
|
||||||
|
settings.bailian_rerank_base_url,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ValueError("workspace endpoint placeholders are not runnable")
|
||||||
|
api_key = settings.bailian_api_key()
|
||||||
|
except (SecretFileError, ValueError):
|
||||||
|
write_json_line(
|
||||||
|
{
|
||||||
|
"capability": "configuration",
|
||||||
|
"status": "failed",
|
||||||
|
"error_kind": "invalid_local_configuration",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
probes = (
|
||||||
|
("embedding", probe_embedding),
|
||||||
|
("rerank", probe_rerank),
|
||||||
|
("chat", probe_chat),
|
||||||
|
)
|
||||||
|
results = []
|
||||||
|
for capability, operation in probes:
|
||||||
|
result = await run_probe(capability, operation, settings, api_key)
|
||||||
|
results.append(result)
|
||||||
|
write_json_line(asdict(result))
|
||||||
|
return 0 if all(result.status == "ok" for result in results) else 1
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
raise SystemExit(asyncio.run(async_main()))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
666
backend/app/tools/seed_demo.py
Normal file
666
backend/app/tools/seed_demo.py
Normal file
@@ -0,0 +1,666 @@
|
|||||||
|
"""Idempotently embed, store, retrieve, and rerank the public synthetic corpus."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from pgvector import Vector
|
||||||
|
from pgvector.psycopg import register_vector
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
|
from app.adapters.bailian import BailianEmbeddingAdapter, BailianRerankerAdapter
|
||||||
|
from app.adapters.fake import FakeEmbeddingProvider, FakeReranker, lexical_features
|
||||||
|
from app.core.config import Settings
|
||||||
|
from app.core.secrets import SecretFileError
|
||||||
|
from app.ports.model_providers import EmbeddingProvider, ModelProviderError, Reranker
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
DEFAULT_SAMPLE_ROOT = PROJECT_ROOT / "data" / "samples" / "public"
|
||||||
|
IDENTITY_NAMESPACE = uuid.UUID("eef85571-1f64-4a09-86d7-53fd329c3eb2")
|
||||||
|
KNOWLEDGE_BASE_ID = uuid.uuid5(IDENTITY_NAMESPACE, "synthetic-demo-knowledge-base")
|
||||||
|
ACCESS_SCOPE_ID = uuid.uuid5(IDENTITY_NAMESPACE, "synthetic-demo-public-scope")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DemoDocument:
|
||||||
|
source_id: str
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
region: str
|
||||||
|
mineral: str
|
||||||
|
page_no: int
|
||||||
|
cloud_policy_id: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DemoQuery:
|
||||||
|
qid: str
|
||||||
|
query: str
|
||||||
|
expected_doc_ids: tuple[str, ...]
|
||||||
|
answerable: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PreparedChunk:
|
||||||
|
source_id: str
|
||||||
|
document_id: uuid.UUID
|
||||||
|
version_id: uuid.UUID
|
||||||
|
chunk_id: uuid.UUID
|
||||||
|
raw_sha256: str
|
||||||
|
cloud_text: str
|
||||||
|
cloud_text_sha256: str
|
||||||
|
embedding_prefix: str
|
||||||
|
embedding_text: str
|
||||||
|
embedding_text_sha256: str
|
||||||
|
outbound_manifest_sha256: str
|
||||||
|
embedding_profile_hash: str
|
||||||
|
vector: tuple[float, ...]
|
||||||
|
embedding_model: str
|
||||||
|
title: str
|
||||||
|
region: str
|
||||||
|
mineral: str
|
||||||
|
page_no: int
|
||||||
|
cloud_policy_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class SeedContractError(ValueError):
|
||||||
|
def __init__(self, code: str) -> None:
|
||||||
|
self.code = code
|
||||||
|
super().__init__(code)
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_text(value: str) -> str:
|
||||||
|
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def load_jsonl(path: Path) -> list[dict[str, Any]]:
|
||||||
|
if not path.is_file():
|
||||||
|
raise SeedContractError("fixture_missing")
|
||||||
|
records: list[dict[str, Any]] = []
|
||||||
|
for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
|
||||||
|
try:
|
||||||
|
value = json.loads(line)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise SeedContractError(f"invalid_jsonl_line_{line_number}") from exc
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise SeedContractError(f"jsonl_object_required_line_{line_number}")
|
||||||
|
records.append(value)
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def load_documents(path: Path) -> list[DemoDocument]:
|
||||||
|
documents = []
|
||||||
|
for value in load_jsonl(path):
|
||||||
|
if value.get("source_type") != "synthetic":
|
||||||
|
raise SeedContractError("non_synthetic_document")
|
||||||
|
if value.get("review_state") != "LOCAL_PARSED_PENDING_CLOUD_REVIEW":
|
||||||
|
raise SeedContractError("invalid_initial_review_state")
|
||||||
|
documents.append(
|
||||||
|
DemoDocument(
|
||||||
|
source_id=str(value["doc_id"]),
|
||||||
|
title=str(value["title"]),
|
||||||
|
content=str(value["content"]),
|
||||||
|
region=str(value["region"]),
|
||||||
|
mineral=str(value["mineral"]),
|
||||||
|
page_no=int(value["page_no"]),
|
||||||
|
cloud_policy_id=str(value["cloud_policy_id"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len(documents) != 20 or len({item.source_id for item in documents}) != 20:
|
||||||
|
raise SeedContractError("expected_twenty_unique_documents")
|
||||||
|
return documents
|
||||||
|
|
||||||
|
|
||||||
|
def load_queries(path: Path) -> list[DemoQuery]:
|
||||||
|
queries = [
|
||||||
|
DemoQuery(
|
||||||
|
qid=str(value["qid"]),
|
||||||
|
query=str(value["query"]),
|
||||||
|
expected_doc_ids=tuple(str(item) for item in value["expected_doc_ids"]),
|
||||||
|
answerable=bool(value["answerable"]),
|
||||||
|
)
|
||||||
|
for value in load_jsonl(path)
|
||||||
|
]
|
||||||
|
if not queries:
|
||||||
|
raise SeedContractError("query_set_empty")
|
||||||
|
return queries
|
||||||
|
|
||||||
|
|
||||||
|
def embedding_profile_hash(settings: Settings, mode: str) -> str:
|
||||||
|
endpoint_identity = "local-fake"
|
||||||
|
model = "fake-feature-hash-v1"
|
||||||
|
api_mode = "deterministic-offline"
|
||||||
|
if mode == "bailian":
|
||||||
|
endpoint_identity = sha256_text(urlsplit(settings.bailian_openai_base_url).hostname or "")
|
||||||
|
model = settings.embedding_model
|
||||||
|
api_mode = "openai-compatible"
|
||||||
|
profile = {
|
||||||
|
"api_mode": api_mode,
|
||||||
|
"dimension": settings.embedding_dimension,
|
||||||
|
"endpoint_identity_hash": endpoint_identity,
|
||||||
|
"model": model,
|
||||||
|
"normalization": "provider-default",
|
||||||
|
"profile_version": 1,
|
||||||
|
}
|
||||||
|
return sha256_text(
|
||||||
|
json.dumps(profile, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def embed_in_batches(
|
||||||
|
provider: EmbeddingProvider,
|
||||||
|
texts: Sequence[str],
|
||||||
|
) -> tuple[tuple[tuple[float, ...], ...], str]:
|
||||||
|
vectors: list[tuple[float, ...]] = []
|
||||||
|
resolved_model: str | None = None
|
||||||
|
for offset in range(0, len(texts), 10):
|
||||||
|
result = await provider.embed_documents(texts[offset : offset + 10])
|
||||||
|
if resolved_model is not None and result.model != resolved_model:
|
||||||
|
raise SeedContractError("embedding_model_changed_between_batches")
|
||||||
|
resolved_model = result.model
|
||||||
|
vectors.extend(result.vectors)
|
||||||
|
if len(vectors) != len(texts) or resolved_model is None:
|
||||||
|
raise SeedContractError("embedding_result_count_mismatch")
|
||||||
|
return tuple(vectors), resolved_model
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_chunks(
|
||||||
|
documents: Sequence[DemoDocument],
|
||||||
|
vectors: Sequence[tuple[float, ...]],
|
||||||
|
*,
|
||||||
|
profile_hash: str,
|
||||||
|
embedding_model: str,
|
||||||
|
) -> list[PreparedChunk]:
|
||||||
|
prepared = []
|
||||||
|
for document, vector in zip(documents, vectors, strict=True):
|
||||||
|
raw_payload = json.dumps(
|
||||||
|
{
|
||||||
|
"content": document.content,
|
||||||
|
"mineral": document.mineral,
|
||||||
|
"page_no": document.page_no,
|
||||||
|
"region": document.region,
|
||||||
|
"title": document.title,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
raw_hash = sha256_text(raw_payload)
|
||||||
|
document_id = uuid.uuid5(IDENTITY_NAMESPACE, f"document:{document.source_id}")
|
||||||
|
version_id = uuid.uuid5(
|
||||||
|
IDENTITY_NAMESPACE,
|
||||||
|
f"version:{document.source_id}:{raw_hash}:{profile_hash}",
|
||||||
|
)
|
||||||
|
chunk_id = uuid.uuid5(IDENTITY_NAMESPACE, f"chunk:{version_id}:0")
|
||||||
|
prefix = (
|
||||||
|
f"标题:{document.title}\n地区:{document.region}\n矿种:{document.mineral}\n正文:"
|
||||||
|
)
|
||||||
|
cloud_hash = sha256_text(document.content)
|
||||||
|
embedding_text = prefix + document.content
|
||||||
|
embedding_hash = sha256_text(embedding_text)
|
||||||
|
manifest_payload = json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"cloud_text_sha256": cloud_hash,
|
||||||
|
"embedding_text_sha256": embedding_hash,
|
||||||
|
"ordinal": 0,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
prepared.append(
|
||||||
|
PreparedChunk(
|
||||||
|
source_id=document.source_id,
|
||||||
|
document_id=document_id,
|
||||||
|
version_id=version_id,
|
||||||
|
chunk_id=chunk_id,
|
||||||
|
raw_sha256=raw_hash,
|
||||||
|
cloud_text=document.content,
|
||||||
|
cloud_text_sha256=cloud_hash,
|
||||||
|
embedding_prefix=prefix,
|
||||||
|
embedding_text=embedding_text,
|
||||||
|
embedding_text_sha256=embedding_hash,
|
||||||
|
outbound_manifest_sha256=sha256_text(manifest_payload),
|
||||||
|
embedding_profile_hash=profile_hash,
|
||||||
|
vector=vector,
|
||||||
|
embedding_model=embedding_model,
|
||||||
|
title=document.title,
|
||||||
|
region=document.region,
|
||||||
|
mineral=document.mineral,
|
||||||
|
page_no=document.page_no,
|
||||||
|
cloud_policy_id=document.cloud_policy_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return prepared
|
||||||
|
|
||||||
|
|
||||||
|
def database_dsn(settings: Settings) -> str:
|
||||||
|
return (
|
||||||
|
settings.database_url().set(drivername="postgresql").render_as_string(hide_password=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def write_chunks(settings: Settings, chunks: Sequence[PreparedChunk]) -> dict[str, int]:
|
||||||
|
with psycopg.connect(database_dsn(settings), row_factory=dict_row) as connection:
|
||||||
|
register_vector(connection)
|
||||||
|
connection.execute("SELECT pg_advisory_xact_lock(724202607120001)")
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO rag.knowledge_bases (id, name, description)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
ON CONFLICT (id) DO UPDATE
|
||||||
|
SET name = EXCLUDED.name,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
updated_at = now()
|
||||||
|
""",
|
||||||
|
(KNOWLEDGE_BASE_ID, "虚构地质 PoC 知识库", "仅含公开的合成验证文本"),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO rag.access_scopes (id, knowledge_base_id, name)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
""",
|
||||||
|
(ACCESS_SCOPE_ID, KNOWLEDGE_BASE_ID, "synthetic-demo"),
|
||||||
|
)
|
||||||
|
|
||||||
|
for item in chunks:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO rag.documents (
|
||||||
|
id, knowledge_base_id, access_scope_id, raw_sha256,
|
||||||
|
filename, storage_key, mime_type, status
|
||||||
|
) VALUES (%s, %s, %s, %s, %s, %s, 'application/json',
|
||||||
|
'LOCAL_PARSED_PENDING_CLOUD_REVIEW')
|
||||||
|
ON CONFLICT (id) DO UPDATE
|
||||||
|
SET filename = EXCLUDED.filename,
|
||||||
|
storage_key = EXCLUDED.storage_key,
|
||||||
|
mime_type = EXCLUDED.mime_type,
|
||||||
|
updated_at = now()
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
item.document_id,
|
||||||
|
KNOWLEDGE_BASE_ID,
|
||||||
|
ACCESS_SCOPE_ID,
|
||||||
|
item.raw_sha256,
|
||||||
|
f"{item.source_id}.json",
|
||||||
|
f"synthetic/{item.source_id}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO rag.document_versions (
|
||||||
|
id, document_id, parser_profile_hash, ocr_profile_hash,
|
||||||
|
normalization_profile_hash, chunk_profile_hash, cloud_policy_id,
|
||||||
|
outbound_manifest_sha256, review_state, embedding_profile_hash,
|
||||||
|
status, expected_chunk_count
|
||||||
|
) VALUES (
|
||||||
|
%s, %s, %s, NULL, %s, %s, %s, %s,
|
||||||
|
'LOCAL_PARSED_PENDING_CLOUD_REVIEW', %s, 'PROCESSING', 1
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
item.version_id,
|
||||||
|
item.document_id,
|
||||||
|
sha256_text("synthetic-jsonl-parser-v1"),
|
||||||
|
sha256_text("identity-normalization-v1"),
|
||||||
|
sha256_text("one-record-one-chunk-v1"),
|
||||||
|
item.cloud_policy_id,
|
||||||
|
item.outbound_manifest_sha256,
|
||||||
|
item.embedding_profile_hash,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO rag.outbound_manifest_items (
|
||||||
|
document_version_id, ordinal, outbound_manifest_sha256,
|
||||||
|
cloud_text_sha256, embedding_text_sha256
|
||||||
|
)
|
||||||
|
SELECT %s, 0, %s, %s, %s
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM rag.outbound_manifest_items
|
||||||
|
WHERE document_version_id = %s AND ordinal = 0
|
||||||
|
)
|
||||||
|
ON CONFLICT (document_version_id, ordinal) DO NOTHING
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
item.version_id,
|
||||||
|
item.outbound_manifest_sha256,
|
||||||
|
item.cloud_text_sha256,
|
||||||
|
item.embedding_text_sha256,
|
||||||
|
item.version_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO rag.chunks (
|
||||||
|
id, knowledge_base_id, document_id, document_version_id,
|
||||||
|
access_scope_id, ordinal, display_text, cloud_text,
|
||||||
|
cloud_text_sha256, embedding_prefix, embedding_text,
|
||||||
|
embedding_text_sha256, embedded_text_sha256,
|
||||||
|
embedding_profile_hash, outbound_manifest_sha256, token_count,
|
||||||
|
page_start, page_end, section_path, metadata, embedding_model,
|
||||||
|
embedding_dimension, embedding, approval_status, index_status,
|
||||||
|
searchable
|
||||||
|
) VALUES (
|
||||||
|
%s, %s, %s, %s, %s, 0, %s, %s, %s, %s, %s, %s, %s,
|
||||||
|
%s, %s, %s, %s, %s, %s, %s, %s, 1024, %s,
|
||||||
|
'LOCAL_PARSED_PENDING_CLOUD_REVIEW', 'READY', false
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
display_text = EXCLUDED.display_text,
|
||||||
|
cloud_text = EXCLUDED.cloud_text,
|
||||||
|
cloud_text_sha256 = EXCLUDED.cloud_text_sha256,
|
||||||
|
embedding_prefix = EXCLUDED.embedding_prefix,
|
||||||
|
embedding_text = EXCLUDED.embedding_text,
|
||||||
|
embedding_text_sha256 = EXCLUDED.embedding_text_sha256,
|
||||||
|
embedded_text_sha256 = EXCLUDED.embedded_text_sha256,
|
||||||
|
embedding_profile_hash = EXCLUDED.embedding_profile_hash,
|
||||||
|
outbound_manifest_sha256 = EXCLUDED.outbound_manifest_sha256,
|
||||||
|
token_count = EXCLUDED.token_count,
|
||||||
|
page_start = EXCLUDED.page_start,
|
||||||
|
page_end = EXCLUDED.page_end,
|
||||||
|
section_path = EXCLUDED.section_path,
|
||||||
|
metadata = EXCLUDED.metadata,
|
||||||
|
embedding_model = EXCLUDED.embedding_model,
|
||||||
|
updated_at = now()
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
item.chunk_id,
|
||||||
|
KNOWLEDGE_BASE_ID,
|
||||||
|
item.document_id,
|
||||||
|
item.version_id,
|
||||||
|
ACCESS_SCOPE_ID,
|
||||||
|
item.cloud_text,
|
||||||
|
item.cloud_text,
|
||||||
|
item.cloud_text_sha256,
|
||||||
|
item.embedding_prefix,
|
||||||
|
item.embedding_text,
|
||||||
|
item.embedding_text_sha256,
|
||||||
|
item.embedding_text_sha256,
|
||||||
|
item.embedding_profile_hash,
|
||||||
|
item.outbound_manifest_sha256,
|
||||||
|
max(1, len(lexical_features(item.embedding_text))),
|
||||||
|
item.page_no,
|
||||||
|
item.page_no,
|
||||||
|
Jsonb([item.title]),
|
||||||
|
Jsonb(
|
||||||
|
{
|
||||||
|
"mineral": item.mineral,
|
||||||
|
"region": item.region,
|
||||||
|
"source_doc_id": item.source_id,
|
||||||
|
"source_type": "synthetic",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
item.embedding_model,
|
||||||
|
Vector(list(item.vector)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE rag.document_versions
|
||||||
|
SET review_state = 'CLOUD_APPROVED',
|
||||||
|
cloud_approved_at = COALESCE(cloud_approved_at, now()),
|
||||||
|
cloud_approved_by = 'seed-demo:synthetic-policy',
|
||||||
|
status = 'READY',
|
||||||
|
completed_at = COALESCE(completed_at, now())
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(item.version_id,),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE rag.chunks
|
||||||
|
SET approval_status = 'CLOUD_APPROVED',
|
||||||
|
index_status = 'READY',
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(item.chunk_id,),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE rag.documents
|
||||||
|
SET active_version_id = %s,
|
||||||
|
raw_sha256 = %s,
|
||||||
|
status = 'READY',
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(item.version_id, item.raw_sha256, item.document_id),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE rag.chunks SET searchable = true, updated_at = now() WHERE id = %s",
|
||||||
|
(item.chunk_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
counts = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
count(*)::integer AS chunks,
|
||||||
|
count(*) FILTER (WHERE embedding IS NOT NULL)::integer AS vectors,
|
||||||
|
count(*) FILTER (WHERE searchable)::integer AS searchable
|
||||||
|
FROM rag.chunks
|
||||||
|
WHERE knowledge_base_id = %s
|
||||||
|
""",
|
||||||
|
(KNOWLEDGE_BASE_ID,),
|
||||||
|
).fetchone()
|
||||||
|
if counts is None:
|
||||||
|
raise SeedContractError("database_count_missing")
|
||||||
|
return {key: int(counts[key]) for key in ("chunks", "vectors", "searchable")}
|
||||||
|
|
||||||
|
|
||||||
|
def retrieve(
|
||||||
|
settings: Settings,
|
||||||
|
query_vector: tuple[float, ...],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
with psycopg.connect(database_dsn(settings), row_factory=dict_row) as connection:
|
||||||
|
register_vector(connection)
|
||||||
|
connection.execute("SET LOCAL hnsw.iterative_scan = strict_order")
|
||||||
|
connection.execute("SET LOCAL hnsw.ef_search = 100")
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, metadata, embedding_text,
|
||||||
|
1 - (embedding <=> %s) AS vector_score
|
||||||
|
FROM rag.chunks
|
||||||
|
WHERE searchable
|
||||||
|
AND knowledge_base_id = %s
|
||||||
|
AND access_scope_id = %s
|
||||||
|
ORDER BY embedding <=> %s
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
Vector(list(query_vector)),
|
||||||
|
KNOWLEDGE_BASE_ID,
|
||||||
|
ACCESS_SCOPE_ID,
|
||||||
|
Vector(list(query_vector)),
|
||||||
|
settings.vector_top_k,
|
||||||
|
),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def evaluate_queries(
|
||||||
|
settings: Settings,
|
||||||
|
queries: Sequence[DemoQuery],
|
||||||
|
embedder: EmbeddingProvider,
|
||||||
|
reranker: Reranker,
|
||||||
|
) -> dict[str, float | int]:
|
||||||
|
hits = 0
|
||||||
|
answerable = 0
|
||||||
|
for query in queries:
|
||||||
|
query_result = await embedder.embed_query(query.query)
|
||||||
|
candidates = retrieve(settings, query_result.vectors[0])
|
||||||
|
if not candidates:
|
||||||
|
continue
|
||||||
|
reranked = await reranker.rerank(
|
||||||
|
query.query,
|
||||||
|
[cast(str, item["embedding_text"]) for item in candidates],
|
||||||
|
top_n=min(settings.rerank_top_n, len(candidates)),
|
||||||
|
)
|
||||||
|
result_doc_ids = [
|
||||||
|
cast(dict[str, Any], candidates[item.index]["metadata"])["source_doc_id"]
|
||||||
|
for item in reranked.items[:3]
|
||||||
|
]
|
||||||
|
if query.answerable:
|
||||||
|
answerable += 1
|
||||||
|
if set(query.expected_doc_ids) & set(result_doc_ids):
|
||||||
|
hits += 1
|
||||||
|
return {
|
||||||
|
"answerable_queries": answerable,
|
||||||
|
"hit_at_3": hits,
|
||||||
|
"hit_rate_at_3": round(hits / answerable, 4) if answerable else 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def output_summary(payload: dict[str, Any]) -> None:
|
||||||
|
sys.stdout.write(json.dumps(payload, ensure_ascii=False, sort_keys=True) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def safe_failure_site(error: BaseException) -> str:
|
||||||
|
traceback = error.__traceback__
|
||||||
|
selected: str | None = None
|
||||||
|
while traceback is not None:
|
||||||
|
filename = Path(traceback.tb_frame.f_code.co_filename).name
|
||||||
|
if filename == "seed_demo.py":
|
||||||
|
selected = f"{filename}:{traceback.tb_frame.f_code.co_name}:{traceback.tb_lineno}"
|
||||||
|
traceback = traceback.tb_next
|
||||||
|
return selected or "external_dependency"
|
||||||
|
|
||||||
|
|
||||||
|
async def async_main() -> int:
|
||||||
|
mode = os.getenv("DEMO_PROVIDER_MODE", "fake").strip().lower()
|
||||||
|
if mode not in {"fake", "bailian"}:
|
||||||
|
output_summary({"status": "failed", "error_kind": "invalid_provider_mode"})
|
||||||
|
return 2
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
documents_path = Path(
|
||||||
|
os.getenv("DEMO_DOCUMENTS_PATH", str(DEFAULT_SAMPLE_ROOT / "demo_documents.jsonl"))
|
||||||
|
)
|
||||||
|
queries_path = Path(
|
||||||
|
os.getenv("DEMO_QUERIES_PATH", str(DEFAULT_SAMPLE_ROOT / "demo_queries.jsonl"))
|
||||||
|
)
|
||||||
|
cloud_embedder: BailianEmbeddingAdapter | None = None
|
||||||
|
cloud_reranker: BailianRerankerAdapter | None = None
|
||||||
|
try:
|
||||||
|
documents = load_documents(documents_path)
|
||||||
|
queries = load_queries(queries_path)
|
||||||
|
profile_hash = embedding_profile_hash(settings, mode)
|
||||||
|
embedder: EmbeddingProvider
|
||||||
|
reranker: Reranker
|
||||||
|
if mode == "bailian":
|
||||||
|
api_key = settings.bailian_api_key()
|
||||||
|
cloud_embedder = BailianEmbeddingAdapter(
|
||||||
|
api_key=api_key,
|
||||||
|
base_url=settings.bailian_openai_base_url,
|
||||||
|
model=settings.embedding_model,
|
||||||
|
dimensions=settings.embedding_dimension,
|
||||||
|
timeout_seconds=settings.model_timeout_seconds,
|
||||||
|
max_retries=settings.model_max_retries,
|
||||||
|
)
|
||||||
|
cloud_reranker = BailianRerankerAdapter(
|
||||||
|
api_key=api_key,
|
||||||
|
base_url=settings.bailian_rerank_base_url,
|
||||||
|
model=settings.rerank_model,
|
||||||
|
timeout_seconds=settings.model_timeout_seconds,
|
||||||
|
max_retries=settings.model_max_retries,
|
||||||
|
)
|
||||||
|
embedder = cloud_embedder
|
||||||
|
reranker = cloud_reranker
|
||||||
|
else:
|
||||||
|
embedder = FakeEmbeddingProvider(settings.embedding_dimension)
|
||||||
|
reranker = FakeReranker()
|
||||||
|
|
||||||
|
texts = [
|
||||||
|
f"标题:{item.title}\n地区:{item.region}\n矿种:{item.mineral}\n正文:{item.content}"
|
||||||
|
for item in documents
|
||||||
|
]
|
||||||
|
vectors, resolved_model = await embed_in_batches(embedder, texts)
|
||||||
|
prepared = prepare_chunks(
|
||||||
|
documents,
|
||||||
|
vectors,
|
||||||
|
profile_hash=profile_hash,
|
||||||
|
embedding_model=resolved_model,
|
||||||
|
)
|
||||||
|
counts = write_chunks(settings, prepared)
|
||||||
|
metrics = await evaluate_queries(settings, queries, embedder, reranker)
|
||||||
|
output_summary(
|
||||||
|
{
|
||||||
|
"counts": counts,
|
||||||
|
"embedding_model": resolved_model,
|
||||||
|
"metrics": metrics,
|
||||||
|
"provider_mode": mode,
|
||||||
|
"status": "ok",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
except ModelProviderError as exc:
|
||||||
|
output_summary(
|
||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"error_kind": f"model_provider_{exc.kind.value}",
|
||||||
|
"status_code": exc.status_code,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
except psycopg.Error as exc:
|
||||||
|
constraint_name = exc.diag.constraint_name
|
||||||
|
output_summary(
|
||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"error_kind": "database_error",
|
||||||
|
"sqlstate": exc.sqlstate,
|
||||||
|
"failure_site": safe_failure_site(exc),
|
||||||
|
"constraint": constraint_name
|
||||||
|
if constraint_name and constraint_name.replace("_", "").isalnum()
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
except SecretFileError:
|
||||||
|
output_summary({"status": "failed", "error_kind": "secret_configuration"})
|
||||||
|
return 1
|
||||||
|
except OSError:
|
||||||
|
output_summary({"status": "failed", "error_kind": "fixture_io_error"})
|
||||||
|
return 1
|
||||||
|
except SeedContractError as exc:
|
||||||
|
output_summary({"status": "failed", "error_kind": "seed_contract_error", "code": exc.code})
|
||||||
|
return 1
|
||||||
|
except ValueError as exc:
|
||||||
|
output_summary(
|
||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"error_kind": "fixture_or_contract_error",
|
||||||
|
"failure_site": safe_failure_site(exc),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
if cloud_embedder is not None:
|
||||||
|
await cloud_embedder.aclose()
|
||||||
|
if cloud_reranker is not None:
|
||||||
|
await cloud_reranker.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
raise SystemExit(asyncio.run(async_main()))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
102
backend/migrations/env.py
Normal file
102
backend/migrations/env.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from logging.config import fileConfig
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.engine import URL
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
|
SCHEMA_NAME = "rag"
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = None
|
||||||
|
|
||||||
|
|
||||||
|
def _required_env(name: str, *, default: str | None = None) -> str:
|
||||||
|
value = os.getenv(name, default)
|
||||||
|
if value is None or not value.strip():
|
||||||
|
raise RuntimeError(f"{name} is required")
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _database_password() -> str:
|
||||||
|
secret_path = os.getenv("POSTGRES_PASSWORD_FILE")
|
||||||
|
if secret_path:
|
||||||
|
password = Path(secret_path).read_text(encoding="utf-8").strip()
|
||||||
|
else:
|
||||||
|
password = os.getenv("POSTGRES_PASSWORD", "").strip()
|
||||||
|
|
||||||
|
if not password:
|
||||||
|
raise RuntimeError(
|
||||||
|
"POSTGRES_PASSWORD_FILE must point to a non-empty secret "
|
||||||
|
"(POSTGRES_PASSWORD is allowed only for explicit local tooling)"
|
||||||
|
)
|
||||||
|
return password
|
||||||
|
|
||||||
|
|
||||||
|
def _database_url() -> URL:
|
||||||
|
raw_port = _required_env("POSTGRES_PORT", default="5432")
|
||||||
|
try:
|
||||||
|
port = int(raw_port)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RuntimeError("POSTGRES_PORT must be an integer") from exc
|
||||||
|
|
||||||
|
return URL.create(
|
||||||
|
drivername="postgresql+psycopg",
|
||||||
|
username=_required_env("POSTGRES_USER"),
|
||||||
|
password=_database_password(),
|
||||||
|
host=_required_env("POSTGRES_HOST", default="db"),
|
||||||
|
port=port,
|
||||||
|
database=_required_env("POSTGRES_DB", default="geological_rag"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
context.configure(
|
||||||
|
# Offline SQL generation needs only the dialect and must not materialize
|
||||||
|
# a real credential in generated output or process diagnostics.
|
||||||
|
url="postgresql+psycopg://",
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
include_schemas=True,
|
||||||
|
version_table_schema=SCHEMA_NAME,
|
||||||
|
transaction_per_migration=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
connectable = create_engine(
|
||||||
|
_database_url(),
|
||||||
|
poolclass=NullPool,
|
||||||
|
connect_args={"options": f"-csearch_path={SCHEMA_NAME},public"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
include_schemas=True,
|
||||||
|
version_table_schema=SCHEMA_NAME,
|
||||||
|
compare_type=True,
|
||||||
|
compare_server_default=True,
|
||||||
|
transaction_per_migration=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
24
backend/migrations/script.py.mako
Normal file
24
backend/migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: str | None = ${repr(down_revision)}
|
||||||
|
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
||||||
|
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
1015
backend/migrations/versions/0001_initial_schema.py
Normal file
1015
backend/migrations/versions/0001_initial_schema.py
Normal file
File diff suppressed because it is too large
Load Diff
58
backend/pyproject.toml
Normal file
58
backend/pyproject.toml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
[project]
|
||||||
|
name = "geological-rag-backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Backend and reproducible PoC tools for the geological prospecting RAG system"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12,<3.13"
|
||||||
|
dependencies = [
|
||||||
|
"alembic>=1.14,<2",
|
||||||
|
"fastapi>=0.115,<1",
|
||||||
|
"httpx>=0.28,<1",
|
||||||
|
"pgvector>=0.3,<1",
|
||||||
|
"psycopg[binary]>=3.2,<4",
|
||||||
|
"pydantic>=2.10,<3",
|
||||||
|
"pydantic-settings>=2.7,<3",
|
||||||
|
"sqlalchemy>=2.0,<3",
|
||||||
|
"uvicorn[standard]>=0.34,<1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"mypy>=1.14,<2",
|
||||||
|
"pytest>=8.3,<10",
|
||||||
|
"pytest-asyncio>=0.25,<2",
|
||||||
|
"ruff>=0.9,<1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling>=1.27,<2"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["app"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
addopts = "-ra --strict-config --strict-markers"
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py312"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "B", "UP", "ASYNC", "S"]
|
||||||
|
ignore = ["S101"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests/**/*.py" = ["S105", "S106", "S603", "S607"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.12"
|
||||||
|
strict = true
|
||||||
|
warn_unreachable = true
|
||||||
|
plugins = ["pydantic.mypy"]
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = ["pgvector.*"]
|
||||||
|
ignore_missing_imports = true
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
175
backend/tests/contract/test_bailian_chat.py
Normal file
175
backend/tests/contract/test_bailian_chat.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.adapters.bailian.chat import BailianChatAdapter
|
||||||
|
from app.ports.model_providers import ChatMessage, ModelProviderError
|
||||||
|
|
||||||
|
BASE_URL = "https://workspace.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"
|
||||||
|
API_KEY = "sk-test-chat-secret"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_completion_disables_thinking_and_search() -> None:
|
||||||
|
messages = [ChatMessage(role="user", content="Give a grounded answer")]
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
assert request.url.path == "/compatible-mode/v1/chat/completions"
|
||||||
|
assert request.headers["Authorization"] == f"Bearer {API_KEY}"
|
||||||
|
payload = json.loads(request.content)
|
||||||
|
assert payload["model"] == "deepseek-v4-flash"
|
||||||
|
assert payload["messages"] == [{"role": "user", "content": "Give a grounded answer"}]
|
||||||
|
assert payload["enable_thinking"] is False
|
||||||
|
assert payload["enable_search"] is False
|
||||||
|
assert payload["stream"] is False
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"id": "req_chat_1",
|
||||||
|
"model": "deepseek-v4-flash",
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"message": {"role": "assistant", "content": "grounded"},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 5,
|
||||||
|
"completion_tokens": 1,
|
||||||
|
"total_tokens": 6,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
adapter = BailianChatAdapter(
|
||||||
|
api_key=API_KEY,
|
||||||
|
base_url=BASE_URL,
|
||||||
|
http_client=client,
|
||||||
|
)
|
||||||
|
result = await adapter.complete(messages, max_tokens=32)
|
||||||
|
|
||||||
|
assert result.content == "grounded"
|
||||||
|
assert result.finish_reason == "stop"
|
||||||
|
assert result.usage.total_tokens == 6
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_stream_disables_thinking_and_search_and_parses_sse() -> None:
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
payload = json.loads(request.content)
|
||||||
|
assert payload["enable_thinking"] is False
|
||||||
|
assert payload["enable_search"] is False
|
||||||
|
assert payload["stream"] is True
|
||||||
|
assert payload["stream_options"] == {"include_usage": True}
|
||||||
|
chunks = (
|
||||||
|
'data: {"id":"req_stream","model":"deepseek-v4-flash",'
|
||||||
|
'"choices":[{"delta":{"content":"矿"},"finish_reason":null}]}\n\n'
|
||||||
|
'data: {"id":"req_stream","model":"deepseek-v4-flash",'
|
||||||
|
'"choices":[{"delta":{"content":"体"},"finish_reason":"stop"}]}\n\n'
|
||||||
|
'data: {"id":"req_stream","model":"deepseek-v4-flash",'
|
||||||
|
'"choices":[],"usage":{"prompt_tokens":4,"completion_tokens":2,'
|
||||||
|
'"total_tokens":6}}\n\n'
|
||||||
|
"data: [DONE]\n\n"
|
||||||
|
)
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
headers={"content-type": "text/event-stream"},
|
||||||
|
content=chunks.encode(),
|
||||||
|
)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
adapter = BailianChatAdapter(
|
||||||
|
api_key=API_KEY,
|
||||||
|
base_url=BASE_URL,
|
||||||
|
http_client=client,
|
||||||
|
)
|
||||||
|
events = [
|
||||||
|
event
|
||||||
|
async for event in adapter.stream(
|
||||||
|
[ChatMessage(role="user", content="question")],
|
||||||
|
max_tokens=32,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert "".join(event.delta for event in events) == "矿体"
|
||||||
|
assert events[1].finish_reason == "stop"
|
||||||
|
assert events[2].usage.total_tokens == 6
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_error_does_not_expose_key_or_message_body() -> None:
|
||||||
|
sensitive_text = "CONFIDENTIAL_PROMPT_BODY"
|
||||||
|
|
||||||
|
def handler(_: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(
|
||||||
|
401,
|
||||||
|
json={
|
||||||
|
"error": {
|
||||||
|
"code": API_KEY,
|
||||||
|
"message": f"{sensitive_text}: {API_KEY}",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
adapter = BailianChatAdapter(
|
||||||
|
api_key=API_KEY,
|
||||||
|
base_url=BASE_URL,
|
||||||
|
http_client=client,
|
||||||
|
)
|
||||||
|
with pytest.raises(ModelProviderError) as exc_info:
|
||||||
|
await adapter.complete(
|
||||||
|
[ChatMessage(role="user", content=sensitive_text)],
|
||||||
|
max_tokens=8,
|
||||||
|
)
|
||||||
|
|
||||||
|
rendered = f"{exc_info.value!s} {exc_info.value!r}"
|
||||||
|
assert API_KEY not in rendered
|
||||||
|
assert sensitive_text not in rendered
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_stream_honors_retry_after_before_any_token() -> None:
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def handler(_: httpx.Request) -> httpx.Response:
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
if calls == 1:
|
||||||
|
return httpx.Response(
|
||||||
|
429,
|
||||||
|
headers={"Retry-After": "0"},
|
||||||
|
json={"error": {"code": "rate_limited"}},
|
||||||
|
)
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
headers={"content-type": "text/event-stream"},
|
||||||
|
content=(
|
||||||
|
'data: {"model":"deepseek-v4-flash",'
|
||||||
|
'"choices":[{"delta":{"content":"正常"},"finish_reason":"stop"}]}\n\n'
|
||||||
|
"data: [DONE]\n\n"
|
||||||
|
).encode(),
|
||||||
|
)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
adapter = BailianChatAdapter(
|
||||||
|
api_key=API_KEY,
|
||||||
|
base_url=BASE_URL,
|
||||||
|
http_client=client,
|
||||||
|
max_retries=1,
|
||||||
|
retry_base_seconds=0,
|
||||||
|
)
|
||||||
|
events = [
|
||||||
|
event
|
||||||
|
async for event in adapter.stream(
|
||||||
|
[ChatMessage(role="user", content="question")],
|
||||||
|
max_tokens=8,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert "".join(event.delta for event in events) == "正常"
|
||||||
|
assert calls == 2
|
||||||
208
backend/tests/contract/test_bailian_embedding.py
Normal file
208
backend/tests/contract/test_bailian_embedding.py
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.adapters.bailian._base import response_model
|
||||||
|
from app.adapters.bailian.embedding import BailianEmbeddingAdapter
|
||||||
|
from app.ports.model_providers import ModelProviderError, ProviderErrorKind
|
||||||
|
|
||||||
|
BASE_URL = "https://workspace.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"
|
||||||
|
API_KEY = "sk-test-embedding-secret"
|
||||||
|
|
||||||
|
|
||||||
|
def _vector(marker: float = 1.0) -> list[float]:
|
||||||
|
return [marker, *([0.0] * 1_023)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_embedding_rejects_non_bailian_endpoint_before_request() -> None:
|
||||||
|
with pytest.raises(ModelProviderError) as exc_info:
|
||||||
|
BailianEmbeddingAdapter(
|
||||||
|
api_key=API_KEY,
|
||||||
|
base_url="https://attacker.invalid/compatible-mode/v1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.kind is ProviderErrorKind.INVALID_REQUEST
|
||||||
|
assert exc_info.value.provider_code == "invalid_base_url"
|
||||||
|
|
||||||
|
|
||||||
|
def test_response_model_falls_back_when_provider_echoes_sensitive_value() -> None:
|
||||||
|
assert (
|
||||||
|
response_model(
|
||||||
|
{"model": API_KEY},
|
||||||
|
"text-embedding-v4",
|
||||||
|
sensitive_values=(API_KEY,),
|
||||||
|
)
|
||||||
|
== "text-embedding-v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_embedding_uses_compatible_endpoint_and_restores_index_order() -> None:
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
assert request.url.path == "/compatible-mode/v1/embeddings"
|
||||||
|
assert request.headers["Authorization"] == f"Bearer {API_KEY}"
|
||||||
|
payload = json.loads(request.content)
|
||||||
|
assert payload == {
|
||||||
|
"model": "text-embedding-v4",
|
||||||
|
"input": ["first", "second"],
|
||||||
|
"dimensions": 1_024,
|
||||||
|
"encoding_format": "float",
|
||||||
|
}
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"id": "req_embedding_1",
|
||||||
|
"model": "text-embedding-v4",
|
||||||
|
"data": [
|
||||||
|
{"index": 1, "embedding": _vector(2.0)},
|
||||||
|
{"index": 0, "embedding": _vector(1.0)},
|
||||||
|
],
|
||||||
|
"usage": {"prompt_tokens": 2, "total_tokens": 2},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
adapter = BailianEmbeddingAdapter(
|
||||||
|
api_key=API_KEY,
|
||||||
|
base_url=BASE_URL,
|
||||||
|
token_counter=len,
|
||||||
|
http_client=client,
|
||||||
|
)
|
||||||
|
result = await adapter.embed_documents(["first", "second"])
|
||||||
|
|
||||||
|
assert len(result.vectors) == 2
|
||||||
|
assert all(len(vector) == 1_024 for vector in result.vectors)
|
||||||
|
assert result.vectors[0][0] == 1.0
|
||||||
|
assert result.vectors[1][0] == 2.0
|
||||||
|
assert result.request_id == "req_embedding_1"
|
||||||
|
assert result.usage.input_tokens == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_embedding_enforces_10_8192_and_33000_token_boundaries() -> None:
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
payload = json.loads(request.content)
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"data": [
|
||||||
|
{"index": index, "embedding": _vector()}
|
||||||
|
for index, _ in enumerate(payload["input"])
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
adapter = BailianEmbeddingAdapter(
|
||||||
|
api_key=API_KEY,
|
||||||
|
base_url=BASE_URL,
|
||||||
|
token_counter=len,
|
||||||
|
http_client=client,
|
||||||
|
)
|
||||||
|
|
||||||
|
await adapter.embed_documents(["x"] * 10)
|
||||||
|
await adapter.embed_documents(["x" * 8_192])
|
||||||
|
await adapter.embed_documents([*(["x" * 8_192] * 4), "x" * 232])
|
||||||
|
assert calls == 3
|
||||||
|
|
||||||
|
invalid_batches = (
|
||||||
|
["x"] * 11,
|
||||||
|
["x" * 8_193],
|
||||||
|
[*(["x" * 8_192] * 4), "x" * 233],
|
||||||
|
)
|
||||||
|
for texts in invalid_batches:
|
||||||
|
with pytest.raises(ModelProviderError) as exc_info:
|
||||||
|
await adapter.embed_documents(texts)
|
||||||
|
assert exc_info.value.kind is ProviderErrorKind.INVALID_REQUEST
|
||||||
|
assert calls == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_embedding_rejects_non_1024_dimensional_response() -> None:
|
||||||
|
def handler(_: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"data": [{"index": 0, "embedding": [1.0] * 1_023}]},
|
||||||
|
)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
adapter = BailianEmbeddingAdapter(
|
||||||
|
api_key=API_KEY,
|
||||||
|
base_url=BASE_URL,
|
||||||
|
token_counter=len,
|
||||||
|
http_client=client,
|
||||||
|
)
|
||||||
|
with pytest.raises(ModelProviderError) as exc_info:
|
||||||
|
await adapter.embed_query("sensitive geological paragraph")
|
||||||
|
|
||||||
|
assert exc_info.value.kind is ProviderErrorKind.INVALID_RESPONSE
|
||||||
|
assert exc_info.value.provider_code == "invalid_embedding_dimensions"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_embedding_provider_error_does_not_expose_key_or_body() -> None:
|
||||||
|
sensitive_text = "CONFIDENTIAL_GEOLOGICAL_BODY"
|
||||||
|
|
||||||
|
def handler(_: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(
|
||||||
|
400,
|
||||||
|
json={
|
||||||
|
"id": sensitive_text,
|
||||||
|
"error": {
|
||||||
|
"code": API_KEY,
|
||||||
|
"message": f"failed for {sensitive_text} using {API_KEY}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
adapter = BailianEmbeddingAdapter(
|
||||||
|
api_key=API_KEY,
|
||||||
|
base_url=BASE_URL,
|
||||||
|
token_counter=len,
|
||||||
|
http_client=client,
|
||||||
|
)
|
||||||
|
with pytest.raises(ModelProviderError) as exc_info:
|
||||||
|
await adapter.embed_query(sensitive_text)
|
||||||
|
|
||||||
|
rendered = f"{exc_info.value!s} {exc_info.value!r}"
|
||||||
|
assert API_KEY not in rendered
|
||||||
|
assert sensitive_text not in rendered
|
||||||
|
assert exc_info.value.provider_code is None
|
||||||
|
assert exc_info.value.request_id is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_embedding_retries_timeout_with_a_fixed_bound() -> None:
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
if calls == 1:
|
||||||
|
raise httpx.ReadTimeout("simulated timeout", request=request)
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"data": [{"index": 0, "embedding": _vector()}]},
|
||||||
|
)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
adapter = BailianEmbeddingAdapter(
|
||||||
|
api_key=API_KEY,
|
||||||
|
base_url=BASE_URL,
|
||||||
|
token_counter=len,
|
||||||
|
http_client=client,
|
||||||
|
max_retries=1,
|
||||||
|
retry_base_seconds=0,
|
||||||
|
)
|
||||||
|
result = await adapter.embed_query("query")
|
||||||
|
|
||||||
|
assert len(result.vectors[0]) == 1_024
|
||||||
|
assert calls == 2
|
||||||
149
backend/tests/contract/test_bailian_rerank.py
Normal file
149
backend/tests/contract/test_bailian_rerank.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.adapters.bailian.rerank import BailianRerankerAdapter
|
||||||
|
from app.ports.model_providers import ModelProviderError, ProviderErrorKind
|
||||||
|
|
||||||
|
BASE_URL = "https://workspace.cn-beijing.maas.aliyuncs.com/compatible-api/v1"
|
||||||
|
API_KEY = "sk-test-rerank-secret"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rerank_uses_separate_endpoint_and_maps_result_indices() -> None:
|
||||||
|
documents = ["copper evidence", "gold evidence", "irrelevant"]
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
assert request.url.path == "/compatible-api/v1/reranks"
|
||||||
|
assert request.headers["Authorization"] == f"Bearer {API_KEY}"
|
||||||
|
payload = json.loads(request.content)
|
||||||
|
assert payload == {
|
||||||
|
"model": "qwen3-rerank",
|
||||||
|
"query": "where is gold",
|
||||||
|
"documents": documents,
|
||||||
|
"top_n": 2,
|
||||||
|
"instruct": "rank geological evidence",
|
||||||
|
}
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"id": "req_rerank_1",
|
||||||
|
"model": "qwen3-rerank",
|
||||||
|
"results": [
|
||||||
|
{"index": 1, "relevance_score": 0.91},
|
||||||
|
{"index": 0, "relevance_score": 0.72},
|
||||||
|
],
|
||||||
|
"usage": {"input_tokens": 42, "total_tokens": 42},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
adapter = BailianRerankerAdapter(
|
||||||
|
api_key=API_KEY,
|
||||||
|
base_url=BASE_URL,
|
||||||
|
token_counter=len,
|
||||||
|
http_client=client,
|
||||||
|
)
|
||||||
|
result = await adapter.rerank(
|
||||||
|
"where is gold",
|
||||||
|
documents,
|
||||||
|
top_n=2,
|
||||||
|
instruct="rank geological evidence",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [item.index for item in result.items] == [1, 0]
|
||||||
|
assert [item.document for item in result.items] == [documents[1], documents[0]]
|
||||||
|
assert [item.relevance_score for item in result.items] == [0.91, 0.72]
|
||||||
|
assert result.request_id == "req_rerank_1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rerank_enforces_500_4000_and_repeated_query_120000_formula() -> None:
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def handler(_: httpx.Request) -> httpx.Response:
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"results": [{"index": 0, "relevance_score": 1.0}]},
|
||||||
|
)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
adapter = BailianRerankerAdapter(
|
||||||
|
api_key=API_KEY,
|
||||||
|
base_url=BASE_URL,
|
||||||
|
token_counter=len,
|
||||||
|
http_client=client,
|
||||||
|
)
|
||||||
|
|
||||||
|
await adapter.rerank("q" * 4_000, ["d" * 4_000], top_n=1)
|
||||||
|
exactly_120000 = ["d" * 40] * 500
|
||||||
|
await adapter.rerank("q" * 200, exactly_120000, top_n=1)
|
||||||
|
assert calls == 2
|
||||||
|
|
||||||
|
invalid_requests = (
|
||||||
|
("q" * 4_001, ["d"]),
|
||||||
|
("q", ["d" * 4_001]),
|
||||||
|
("q", ["d"] * 501),
|
||||||
|
("q" * 200, [*(["d" * 40] * 499), "d" * 41]),
|
||||||
|
)
|
||||||
|
for query, documents in invalid_requests:
|
||||||
|
with pytest.raises(ModelProviderError) as exc_info:
|
||||||
|
await adapter.rerank(query, documents, top_n=1)
|
||||||
|
assert exc_info.value.kind is ProviderErrorKind.INVALID_REQUEST
|
||||||
|
assert calls == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rerank_rejects_invalid_provider_index() -> None:
|
||||||
|
def handler(_: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"results": [{"index": 2, "relevance_score": 0.9}]},
|
||||||
|
)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
adapter = BailianRerankerAdapter(
|
||||||
|
api_key=API_KEY,
|
||||||
|
base_url=BASE_URL,
|
||||||
|
token_counter=len,
|
||||||
|
http_client=client,
|
||||||
|
)
|
||||||
|
with pytest.raises(ModelProviderError) as exc_info:
|
||||||
|
await adapter.rerank("query", ["only document"], top_n=1)
|
||||||
|
|
||||||
|
assert exc_info.value.kind is ProviderErrorKind.INVALID_RESPONSE
|
||||||
|
assert exc_info.value.provider_code == "invalid_rerank_index"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rerank_retries_5xx_with_a_fixed_bound() -> None:
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def handler(_: httpx.Request) -> httpx.Response:
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
if calls == 1:
|
||||||
|
return httpx.Response(503, json={"error": {"code": "temporarily_unavailable"}})
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"results": [{"index": 0, "relevance_score": 0.9}]},
|
||||||
|
)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
adapter = BailianRerankerAdapter(
|
||||||
|
api_key=API_KEY,
|
||||||
|
base_url=BASE_URL,
|
||||||
|
token_counter=len,
|
||||||
|
http_client=client,
|
||||||
|
max_retries=1,
|
||||||
|
retry_base_seconds=0,
|
||||||
|
)
|
||||||
|
result = await adapter.rerank("query", ["document"], top_n=1)
|
||||||
|
|
||||||
|
assert result.items[0].index == 0
|
||||||
|
assert calls == 2
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
183
backend/tests/integration/test_schema_contract.py
Normal file
183
backend/tests/integration/test_schema_contract.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
BACKEND = ROOT / "backend"
|
||||||
|
sys.path.insert(0, str(BACKEND))
|
||||||
|
|
||||||
|
from app.persistence.job_queue_sql import ( # noqa: E402
|
||||||
|
CLAIM_JOB_SQL,
|
||||||
|
COMPLETE_JOB_SQL,
|
||||||
|
FAIL_OR_RETRY_JOB_SQL,
|
||||||
|
HEARTBEAT_JOB_SQL,
|
||||||
|
REAP_EXPIRED_JOBS_SQL,
|
||||||
|
)
|
||||||
|
|
||||||
|
COMPOSE = (ROOT / "compose.yaml").read_text(encoding="utf-8")
|
||||||
|
BOOTSTRAP = (ROOT / "ops/postgres/init/10-bootstrap-rag.sh").read_text(encoding="utf-8")
|
||||||
|
MIGRATION = (BACKEND / "migrations/versions/0001_initial_schema.py").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _service_block(name: str) -> str:
|
||||||
|
pattern = re.compile(rf"(?ms)^ {re.escape(name)}:\n(.*?)(?=^ [a-zA-Z0-9_-]+:\n|\Z)")
|
||||||
|
match = pattern.search(COMPOSE)
|
||||||
|
assert match is not None, f"missing Compose service: {name}"
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_isolates_database_credentials_and_networks() -> None:
|
||||||
|
db = _service_block("db")
|
||||||
|
migrate = _service_block("migrate")
|
||||||
|
provider_smoke = _service_block("provider-smoke")
|
||||||
|
seed_demo = _service_block("seed-demo")
|
||||||
|
seed_demo_offline = _service_block("seed-demo-offline")
|
||||||
|
|
||||||
|
assert "postgres_bootstrap_password" in db
|
||||||
|
assert "postgres_migrator_password" in db
|
||||||
|
assert "postgres_app_password" in db
|
||||||
|
|
||||||
|
assert "postgres_migrator_password" in migrate
|
||||||
|
assert "postgres_bootstrap_password" not in migrate
|
||||||
|
assert "postgres_app_password" not in migrate
|
||||||
|
|
||||||
|
assert "bailian_api_key" in provider_smoke
|
||||||
|
assert "postgres_" not in provider_smoke
|
||||||
|
|
||||||
|
assert "postgres_app_password" in seed_demo
|
||||||
|
assert "postgres_bootstrap_password" not in seed_demo
|
||||||
|
assert "postgres_migrator_password" not in seed_demo
|
||||||
|
assert "bailian_api_key" in seed_demo
|
||||||
|
assert "./data/samples/public:/demo:ro" in seed_demo
|
||||||
|
|
||||||
|
assert "postgres_app_password" in seed_demo_offline
|
||||||
|
assert "bailian_api_key" not in seed_demo_offline
|
||||||
|
assert "DEMO_PROVIDER_MODE: fake" in seed_demo_offline
|
||||||
|
assert "./data/samples/public:/demo:ro" in seed_demo_offline
|
||||||
|
|
||||||
|
assert re.search(r"(?ms)^ data:\n.*?^ internal: true$", COMPOSE)
|
||||||
|
assert re.search(r"(?m)^ edge:$", COMPOSE)
|
||||||
|
assert re.search(r"(?m)^ egress:$", COMPOSE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_database_health_requires_tcp_and_atomic_bootstrap_sentinel() -> None:
|
||||||
|
db = _service_block("db")
|
||||||
|
assert "pg_isready -h 127.0.0.1 -p 5432" in db
|
||||||
|
assert ".rag-bootstrap-complete" in db
|
||||||
|
|
||||||
|
assert "CREATE EXTENSION IF NOT EXISTS vector" in BOOTSTRAP
|
||||||
|
assert 'CREATE ROLE :"migrator_user"' in BOOTSTRAP
|
||||||
|
assert 'CREATE ROLE :"app_user"' in BOOTSTRAP
|
||||||
|
assert "NOSUPERUSER" in BOOTSTRAP
|
||||||
|
assert "CREATE SCHEMA rag AUTHORIZATION" in BOOTSTRAP
|
||||||
|
assert "ALTER DEFAULT PRIVILEGES" in BOOTSTRAP
|
||||||
|
assert BOOTSTRAP.index("COMMIT;") < BOOTSTRAP.index("mv -f")
|
||||||
|
assert "\\getenv migrator_password RAG_MIGRATOR_PASSWORD" in BOOTSTRAP
|
||||||
|
assert "--set=migrator_password" not in BOOTSTRAP
|
||||||
|
assert "--set=app_password" not in BOOTSTRAP
|
||||||
|
assert "set -x" not in BOOTSTRAP
|
||||||
|
assert "official postgres entrypoint resolves POSTGRES_PASSWORD_FILE" in BOOTSTRAP
|
||||||
|
assert 'bootstrap_password="${POSTGRES_PASSWORD:-}"' in BOOTSTRAP
|
||||||
|
|
||||||
|
|
||||||
|
def test_initial_migration_has_vector_and_activation_contracts() -> None:
|
||||||
|
normalized = " ".join(MIGRATION.lower().split())
|
||||||
|
|
||||||
|
assert "embedding vector(1024)" in normalized
|
||||||
|
assert "using hnsw (embedding vector_cosine_ops)" in normalized
|
||||||
|
assert "where searchable" in normalized
|
||||||
|
assert "chunks_searchable_requires_ready_approved_embedding" in normalized
|
||||||
|
assert "check (embedding_dimension = 1024)" in normalized
|
||||||
|
assert "active_version_id uuid" in normalized
|
||||||
|
assert "documents_active_version_fk" in normalized
|
||||||
|
assert "deferrable initially deferred" in normalized
|
||||||
|
assert "foreign key (id, active_version_id)" in normalized
|
||||||
|
assert "references rag.document_versions (document_id, id)" in normalized
|
||||||
|
assert "local_parsed_pending_cloud_review" in normalized
|
||||||
|
assert "cloud_approved" in normalized
|
||||||
|
assert "outbound_manifest_sha256" in normalized
|
||||||
|
assert "cloud_processing_allowed" not in normalized
|
||||||
|
assert "document_versions_cloud_approval_bound" in normalized
|
||||||
|
assert "chunks_approval_binding_fk" in normalized
|
||||||
|
assert "create table rag.outbound_manifest_items" in normalized
|
||||||
|
assert "chunks_manifest_item_binding_fk" in normalized
|
||||||
|
assert "outbound_manifest_items_immutable_after_approval" in normalized
|
||||||
|
assert "chunks_guard_approved_mutation" in normalized
|
||||||
|
assert "chunks_guard_ready_vector_update" in normalized
|
||||||
|
assert "new.access_scope_id is distinct from old.access_scope_id" in normalized
|
||||||
|
assert "new.document_version_id is distinct from old.document_version_id" in normalized
|
||||||
|
assert "cloud_text text not null" in normalized
|
||||||
|
assert "cloud_text_sha256 char(64) not null" in normalized
|
||||||
|
assert "embedding_text = embedding_prefix || cloud_text" in normalized
|
||||||
|
assert "sha256(convert_to(cloud_text, 'utf8'))" in normalized
|
||||||
|
assert "sha256(convert_to(embedding_text, 'utf8'))" in normalized
|
||||||
|
assert "approval_status = 'cloud_approved'" in normalized
|
||||||
|
assert "document_versions_revoke_cloud_approval" in normalized
|
||||||
|
assert "new.review_state is distinct from old.review_state" in normalized
|
||||||
|
assert "set searchable = false" in normalized
|
||||||
|
assert "approval_status = revoked_state" in normalized
|
||||||
|
assert "embedding = null" in normalized
|
||||||
|
assert "embedding_profile_hash = null" in normalized
|
||||||
|
assert "where document_version_id = old.id" in normalized
|
||||||
|
assert "chunks_enforce_active_search_projection" in normalized
|
||||||
|
assert "document.active_version_id = new.document_version_id" in normalized
|
||||||
|
assert "version.status = 'ready'" in normalized
|
||||||
|
assert "documents_enforce_activation" in normalized
|
||||||
|
|
||||||
|
|
||||||
|
def test_downgrade_drops_manifest_items_before_document_versions() -> None:
|
||||||
|
normalized = " ".join(MIGRATION.lower().split())
|
||||||
|
|
||||||
|
manifest_drop = normalized.index("drop table if exists rag.outbound_manifest_items")
|
||||||
|
version_drop = normalized.index("drop table if exists rag.document_versions")
|
||||||
|
assert manifest_drop < version_drop
|
||||||
|
|
||||||
|
|
||||||
|
def test_initial_migration_has_fenced_job_schema_and_indexes() -> None:
|
||||||
|
normalized = " ".join(MIGRATION.lower().split())
|
||||||
|
|
||||||
|
assert "create table rag.background_jobs" in normalized
|
||||||
|
assert "lease_owner text" in normalized
|
||||||
|
assert "lease_token uuid" in normalized
|
||||||
|
assert "lease_until timestamptz" in normalized
|
||||||
|
assert "attempt <= max_attempts" in normalized
|
||||||
|
assert "background_jobs_lease_consistent" in normalized
|
||||||
|
assert "unique (job_type, idempotency_key)" in normalized
|
||||||
|
assert "background_jobs_claim_queued" in normalized
|
||||||
|
assert "background_jobs_reap_expired" in normalized
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_claim_and_terminal_updates_are_fenced() -> None:
|
||||||
|
claim = " ".join(CLAIM_JOB_SQL.lower().split())
|
||||||
|
heartbeat = " ".join(HEARTBEAT_JOB_SQL.lower().split())
|
||||||
|
complete = " ".join(COMPLETE_JOB_SQL.lower().split())
|
||||||
|
failure = " ".join(FAIL_OR_RETRY_JOB_SQL.lower().split())
|
||||||
|
|
||||||
|
assert "for update skip locked" in claim
|
||||||
|
assert "lease_token = gen_random_uuid()" in claim
|
||||||
|
assert "attempt = job.attempt + 1" in claim
|
||||||
|
|
||||||
|
for statement in (heartbeat, complete, failure):
|
||||||
|
assert "job.status = 'running'" in statement
|
||||||
|
assert "job.lease_owner = :worker_id" in statement
|
||||||
|
assert "job.lease_token = :lease_token" in statement
|
||||||
|
assert "returning" in statement
|
||||||
|
|
||||||
|
assert "when job.attempt < job.max_attempts then 'queued'" in failure
|
||||||
|
assert "else 'failed'" in failure
|
||||||
|
|
||||||
|
|
||||||
|
def test_reaper_uses_advisory_lock_and_handles_exhausted_attempts() -> None:
|
||||||
|
reaper = " ".join(REAP_EXPIRED_JOBS_SQL.lower().split())
|
||||||
|
|
||||||
|
assert "pg_try_advisory_xact_lock" in reaper
|
||||||
|
assert "job.status = 'running'" in reaper
|
||||||
|
assert "job.lease_until < now()" in reaper
|
||||||
|
assert "for update of job skip locked" in reaper
|
||||||
|
assert "when job.attempt < job.max_attempts then 'queued'" in reaper
|
||||||
|
assert "else 'failed'" in reaper
|
||||||
|
assert "lease_owner = null" in reaper
|
||||||
|
assert "lease_token = null" in reaper
|
||||||
|
assert "lease_until = null" in reaper
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
74
backend/tests/unit/test_config_and_secrets.py
Normal file
74
backend/tests/unit/test_config_and_secrets.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.core.config import Settings
|
||||||
|
from app.core.secrets import SecretFileError, read_secret_file
|
||||||
|
|
||||||
|
|
||||||
|
def test_secret_file_strips_single_trailing_newline(tmp_path: Path) -> None:
|
||||||
|
secret_path = tmp_path / "secret"
|
||||||
|
secret_path.write_text("test-only-value\n", encoding="utf-8")
|
||||||
|
|
||||||
|
assert read_secret_file(secret_path) == "test-only-value"
|
||||||
|
|
||||||
|
|
||||||
|
def test_secret_error_never_contains_secret_value(tmp_path: Path) -> None:
|
||||||
|
secret_path = tmp_path / "secret"
|
||||||
|
secret_path.write_text("first\nsecond\n", encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(SecretFileError) as captured:
|
||||||
|
read_secret_file(secret_path)
|
||||||
|
|
||||||
|
assert "first" not in str(captured.value)
|
||||||
|
assert "second" not in str(captured.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_database_url_reads_password_from_file(tmp_path: Path) -> None:
|
||||||
|
password_path = tmp_path / "postgres_password"
|
||||||
|
password_path.write_text("local-test-password", encoding="utf-8")
|
||||||
|
settings = Settings(postgres_password_file=password_path)
|
||||||
|
|
||||||
|
url = settings.database_url()
|
||||||
|
|
||||||
|
assert url.password == "local-test-password"
|
||||||
|
assert "local-test-password" not in str(url)
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_urls_drop_trailing_slashes() -> None:
|
||||||
|
settings = Settings(
|
||||||
|
bailian_openai_base_url="https://example.invalid/compatible-mode/v1/",
|
||||||
|
bailian_native_base_url="https://example.invalid/api/v1/",
|
||||||
|
bailian_rerank_base_url="https://example.invalid/compatible-api/v1/",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert settings.bailian_openai_base_url.endswith("/v1")
|
||||||
|
assert settings.bailian_rerank_base_url.endswith("/v1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_endpoints_must_share_one_beijing_workspace(tmp_path: Path) -> None:
|
||||||
|
key_path = tmp_path / "key"
|
||||||
|
key_path.write_text("test-key-value", encoding="utf-8")
|
||||||
|
settings = Settings(
|
||||||
|
dashscope_api_key_file=key_path,
|
||||||
|
bailian_openai_base_url=(
|
||||||
|
"https://workspace-a.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"
|
||||||
|
),
|
||||||
|
bailian_rerank_base_url=(
|
||||||
|
"https://workspace-b.cn-beijing.maas.aliyuncs.com/compatible-api/v1"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="same workspace"):
|
||||||
|
settings.bailian_api_key()
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_endpoint_rejects_non_bailian_host_before_reading_key(tmp_path: Path) -> None:
|
||||||
|
settings = Settings(
|
||||||
|
dashscope_api_key_file=tmp_path / "missing",
|
||||||
|
bailian_openai_base_url="https://attacker.invalid/compatible-mode/v1",
|
||||||
|
bailian_rerank_base_url="https://attacker.invalid/compatible-api/v1",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="approved Beijing"):
|
||||||
|
settings.bailian_api_key()
|
||||||
37
backend/tests/unit/test_demo_data.py
Normal file
37
backend/tests/unit/test_demo_data.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
SAMPLE_ROOT = PROJECT_ROOT / "data" / "samples" / "public"
|
||||||
|
|
||||||
|
|
||||||
|
def load_jsonl(path: Path) -> list[dict[str, Any]]:
|
||||||
|
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_corpus_is_synthetic_pending_hash_bound_approval_and_unique() -> None:
|
||||||
|
documents = load_jsonl(SAMPLE_ROOT / "demo_documents.jsonl")
|
||||||
|
|
||||||
|
assert len(documents) == 20
|
||||||
|
assert len({document["doc_id"] for document in documents}) == 20
|
||||||
|
assert all(document["source_type"] == "synthetic" for document in documents)
|
||||||
|
assert all(
|
||||||
|
document["review_state"] == "LOCAL_PARSED_PENDING_CLOUD_REVIEW" for document in documents
|
||||||
|
)
|
||||||
|
assert all(document["cloud_policy_id"] == "synthetic-demo-v1" for document in documents)
|
||||||
|
assert all("cloud_approved" not in document for document in documents)
|
||||||
|
assert all(
|
||||||
|
"虚构" in document["content"] or "演示" in document["content"] for document in documents
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_queries_only_reference_existing_documents() -> None:
|
||||||
|
documents = load_jsonl(SAMPLE_ROOT / "demo_documents.jsonl")
|
||||||
|
queries = load_jsonl(SAMPLE_ROOT / "demo_queries.jsonl")
|
||||||
|
document_ids = {document["doc_id"] for document in documents}
|
||||||
|
|
||||||
|
assert len(queries) == 10
|
||||||
|
assert len({query["qid"] for query in queries}) == 10
|
||||||
|
assert all(set(query["expected_doc_ids"]) <= document_ids for query in queries)
|
||||||
|
assert any(not query["answerable"] and not query["expected_doc_ids"] for query in queries)
|
||||||
47
backend/tests/unit/test_fake_models.py
Normal file
47
backend/tests/unit/test_fake_models.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import math
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.adapters.fake import FakeEmbeddingProvider, FakeReranker
|
||||||
|
from app.ports.model_providers import ModelProviderError
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fake_embedding_is_deterministic_normalized_and_1024_dimensional() -> None:
|
||||||
|
provider = FakeEmbeddingProvider()
|
||||||
|
|
||||||
|
first = await provider.embed_documents(["斑岩铜矿 钾化 绢英岩化"])
|
||||||
|
second = await provider.embed_query("斑岩铜矿 钾化 绢英岩化")
|
||||||
|
|
||||||
|
assert first.vectors[0] == second.vectors[0]
|
||||||
|
assert len(first.vectors[0]) == 1024
|
||||||
|
assert math.isclose(sum(value * value for value in first.vectors[0]), 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fake_reranker_preserves_original_candidate_index() -> None:
|
||||||
|
reranker = FakeReranker()
|
||||||
|
documents = ["煤层测井对比", "斑岩铜矿钾化带", "铝土矿含矿层"]
|
||||||
|
|
||||||
|
result = await reranker.rerank("斑岩铜矿的钾化带", documents, top_n=2)
|
||||||
|
|
||||||
|
assert result.items[0].index == 1
|
||||||
|
assert result.items[0].document == documents[1]
|
||||||
|
assert result.items[0].relevance_score >= result.items[1].relevance_score
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fake_providers_reject_inputs_the_live_contract_rejects() -> None:
|
||||||
|
embedder = FakeEmbeddingProvider()
|
||||||
|
reranker = FakeReranker()
|
||||||
|
|
||||||
|
with pytest.raises(ModelProviderError):
|
||||||
|
await embedder.embed_documents([])
|
||||||
|
with pytest.raises(ModelProviderError):
|
||||||
|
await embedder.embed_documents(["x"] * 11)
|
||||||
|
with pytest.raises(ModelProviderError):
|
||||||
|
await reranker.rerank("", ["document"], top_n=1)
|
||||||
|
with pytest.raises(ModelProviderError):
|
||||||
|
await reranker.rerank("query", [], top_n=1)
|
||||||
|
with pytest.raises(ModelProviderError):
|
||||||
|
await reranker.rerank("query", ["document"], top_n=0)
|
||||||
30
backend/tests/unit/test_health_api.py
Normal file
30
backend/tests/unit/test_health_api.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_liveness_does_not_require_database_or_model_credentials() -> None:
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.get("/api/v1/health/live")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_meta_exposes_model_names_but_no_credentials() -> None:
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.get("/api/v1/meta")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["models"] == {
|
||||||
|
"embedding": "text-embedding-v4",
|
||||||
|
"rerank": "qwen3-rerank",
|
||||||
|
"generation": "deepseek-v4-flash",
|
||||||
|
}
|
||||||
|
assert "key" not in str(payload).lower()
|
||||||
50
backend/tests/unit/test_offline_retrieval.py
Normal file
50
backend/tests/unit/test_offline_retrieval.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.adapters.fake import FakeEmbeddingProvider, FakeReranker
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
SAMPLE_ROOT = PROJECT_ROOT / "data" / "samples" / "public"
|
||||||
|
|
||||||
|
|
||||||
|
def load_jsonl(path: Path) -> list[dict[str, Any]]:
|
||||||
|
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]
|
||||||
|
|
||||||
|
|
||||||
|
def cosine(left: tuple[float, ...], right: tuple[float, ...]) -> float:
|
||||||
|
return sum(
|
||||||
|
left_value * right_value for left_value, right_value in zip(left, right, strict=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_synthetic_questions_retrieve_expected_document_after_rerank() -> None:
|
||||||
|
documents = load_jsonl(SAMPLE_ROOT / "demo_documents.jsonl")
|
||||||
|
queries = load_jsonl(SAMPLE_ROOT / "demo_queries.jsonl")
|
||||||
|
embedder = FakeEmbeddingProvider()
|
||||||
|
reranker = FakeReranker()
|
||||||
|
document_texts = [f"{item['title']}\n{item['content']}" for item in documents]
|
||||||
|
document_vectors: list[tuple[float, ...]] = []
|
||||||
|
for offset in range(0, len(document_texts), 10):
|
||||||
|
batch = await embedder.embed_documents(document_texts[offset : offset + 10])
|
||||||
|
document_vectors.extend(batch.vectors)
|
||||||
|
|
||||||
|
hits = 0
|
||||||
|
answerable_queries = [query for query in queries if query["answerable"]]
|
||||||
|
for query in answerable_queries:
|
||||||
|
query_vector = (await embedder.embed_query(query["query"])).vectors[0]
|
||||||
|
candidate_indexes = sorted(
|
||||||
|
range(len(documents)),
|
||||||
|
key=lambda index: cosine(query_vector, document_vectors[index]),
|
||||||
|
reverse=True,
|
||||||
|
)[:5]
|
||||||
|
candidate_texts = [document_texts[index] for index in candidate_indexes]
|
||||||
|
reranked = await reranker.rerank(query["query"], candidate_texts, top_n=3)
|
||||||
|
result_ids = [documents[candidate_indexes[item.index]]["doc_id"] for item in reranked.items]
|
||||||
|
if set(query["expected_doc_ids"]) & set(result_ids):
|
||||||
|
hits += 1
|
||||||
|
|
||||||
|
assert hits / len(answerable_queries) >= 0.8
|
||||||
26
backend/tests/unit/test_provider_smoke.py
Normal file
26
backend/tests/unit/test_provider_smoke.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from app.ports.model_providers import ModelProviderError, ProviderErrorKind
|
||||||
|
from app.tools.provider_smoke import failed_probe
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_probe_only_exposes_sanitized_provider_fields() -> None:
|
||||||
|
error = ModelProviderError(
|
||||||
|
operation="embedding.create",
|
||||||
|
kind=ProviderErrorKind.AUTHENTICATION,
|
||||||
|
status_code=401,
|
||||||
|
provider_code="invalid_api_key",
|
||||||
|
request_id="req-safe-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = failed_probe("embedding", error)
|
||||||
|
|
||||||
|
assert result.error_kind == "authentication"
|
||||||
|
assert result.status_code == 401
|
||||||
|
assert result.request_id == "req-safe-1"
|
||||||
|
assert "invalid_api_key" not in repr(result)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unexpected_error_is_reduced_to_fixed_category() -> None:
|
||||||
|
result = failed_probe("chat", RuntimeError("sensitive content"))
|
||||||
|
|
||||||
|
assert result.error_kind == "internal_contract_error"
|
||||||
|
assert "sensitive content" not in repr(result)
|
||||||
38
backend/tests/unit/test_secret_scanner.py
Normal file
38
backend/tests/unit/test_secret_scanner.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
SCANNER = PROJECT_ROOT / "scripts" / "check-secrets.sh"
|
||||||
|
|
||||||
|
|
||||||
|
def run_git(repository: Path, *arguments: str) -> None:
|
||||||
|
subprocess.run(
|
||||||
|
["git", *arguments],
|
||||||
|
cwd=repository,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_staged_scan_reads_index_blob_not_modified_worktree(tmp_path: Path) -> None:
|
||||||
|
run_git(tmp_path, "init", "--quiet")
|
||||||
|
run_git(tmp_path, "config", "user.email", "test@example.invalid")
|
||||||
|
run_git(tmp_path, "config", "user.name", "Secret Scanner Test")
|
||||||
|
candidate = tmp_path / "config.txt"
|
||||||
|
candidate.write_text("sk-" + "A" * 30, encoding="utf-8")
|
||||||
|
run_git(tmp_path, "add", "config.txt")
|
||||||
|
candidate.write_text("safe working-tree replacement", encoding="utf-8")
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[str(SCANNER), "--staged"],
|
||||||
|
cwd=tmp_path,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 1
|
||||||
|
assert "config.txt" in result.stderr
|
||||||
|
assert "safe working-tree replacement" not in result.stderr
|
||||||
|
assert "A" * 30 not in result.stderr
|
||||||
54
backend/tests/unit/test_seed_demo.py
Normal file
54
backend/tests/unit/test_seed_demo.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.adapters.fake import FakeEmbeddingProvider
|
||||||
|
from app.core.config import Settings
|
||||||
|
from app.tools.seed_demo import (
|
||||||
|
embed_in_batches,
|
||||||
|
embedding_profile_hash,
|
||||||
|
load_documents,
|
||||||
|
prepare_chunks,
|
||||||
|
)
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
DOCUMENTS_PATH = PROJECT_ROOT / "data" / "samples" / "public" / "demo_documents.jsonl"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_seed_preparation_batches_and_hash_binds_twenty_documents() -> None:
|
||||||
|
documents = load_documents(DOCUMENTS_PATH)
|
||||||
|
settings = Settings()
|
||||||
|
provider = FakeEmbeddingProvider()
|
||||||
|
texts = [
|
||||||
|
f"标题:{item.title}\n地区:{item.region}\n矿种:{item.mineral}\n正文:{item.content}"
|
||||||
|
for item in documents
|
||||||
|
]
|
||||||
|
|
||||||
|
vectors, model = await embed_in_batches(provider, texts)
|
||||||
|
chunks = prepare_chunks(
|
||||||
|
documents,
|
||||||
|
vectors,
|
||||||
|
profile_hash=embedding_profile_hash(settings, "fake"),
|
||||||
|
embedding_model=model,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(chunks) == 20
|
||||||
|
assert all(len(item.vector) == 1024 for item in chunks)
|
||||||
|
assert all(item.embedding_text == item.embedding_prefix + item.cloud_text for item in chunks)
|
||||||
|
assert len({item.chunk_id for item in chunks}) == 20
|
||||||
|
assert all(len(item.outbound_manifest_sha256) == 64 for item in chunks)
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_rejects_fixture_not_explicitly_marked_synthetic(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "documents.jsonl"
|
||||||
|
path.write_text(
|
||||||
|
'{"doc_id":"x","title":"x","content":"x","region":"x",'
|
||||||
|
'"mineral":"x","page_no":1,"source_type":"unknown",'
|
||||||
|
'"review_state":"LOCAL_PARSED_PENDING_CLOUD_REVIEW",'
|
||||||
|
'"cloud_policy_id":"synthetic-demo-v1"}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="non_synthetic_document"):
|
||||||
|
load_documents(path)
|
||||||
680
backend/uv.lock
generated
Normal file
680
backend/uv.lock
generated
Normal file
@@ -0,0 +1,680 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = "==3.12.*"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alembic"
|
||||||
|
version = "1.18.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "mako" },
|
||||||
|
{ name = "sqlalchemy" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1a/cc/ac0bed8e562e7407fe55c3ba85a4dce86e6dbd8730887bd1e406a6c5c18a/alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e", size = 2060480, upload-time = "2026-06-25T15:20:54.888Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/78/5fe6dc3a3a5b2f5a2a4faef8bfe336d5fa049a38884ab3172e0098160c01/alembic-1.18.5-py3-none-any.whl", hash = "sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc", size = 264664, upload-time = "2026-06-25T15:20:56.673Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotated-doc"
|
||||||
|
version = "0.0.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotated-types"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyio"
|
||||||
|
version = "4.14.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2026.6.17"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "click"
|
||||||
|
version = "8.4.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastapi"
|
||||||
|
version = "0.139.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "annotated-doc" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "starlette" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "geological-rag-backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { editable = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "alembic" },
|
||||||
|
{ name = "fastapi" },
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "pgvector" },
|
||||||
|
{ name = "psycopg", extra = ["binary"] },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "pydantic-settings" },
|
||||||
|
{ name = "sqlalchemy" },
|
||||||
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "mypy" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-asyncio" },
|
||||||
|
{ name = "ruff" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "alembic", specifier = ">=1.14,<2" },
|
||||||
|
{ name = "fastapi", specifier = ">=0.115,<1" },
|
||||||
|
{ name = "httpx", specifier = ">=0.28,<1" },
|
||||||
|
{ name = "pgvector", specifier = ">=0.3,<1" },
|
||||||
|
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4" },
|
||||||
|
{ name = "pydantic", specifier = ">=2.10,<3" },
|
||||||
|
{ name = "pydantic-settings", specifier = ">=2.7,<3" },
|
||||||
|
{ name = "sqlalchemy", specifier = ">=2.0,<3" },
|
||||||
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34,<1" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [
|
||||||
|
{ name = "mypy", specifier = ">=1.14,<2" },
|
||||||
|
{ name = "pytest", specifier = ">=8.3,<10" },
|
||||||
|
{ name = "pytest-asyncio", specifier = ">=0.25,<2" },
|
||||||
|
{ name = "ruff", specifier = ">=0.9,<1" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "greenlet"
|
||||||
|
version = "3.5.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h11"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpcore"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httptools"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpx"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "httpcore" },
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.18"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "librt"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mako"
|
||||||
|
version = "1.3.12"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markupsafe"
|
||||||
|
version = "3.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mypy"
|
||||||
|
version = "1.20.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
|
||||||
|
{ name = "mypy-extensions" },
|
||||||
|
{ name = "pathspec" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mypy-extensions"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pathspec"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pgvector"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a7/ec/6eb80aebc728200f95229219882994c1b0585b956ca47da5edb9d062627a/pgvector-0.5.0.tar.gz", hash = "sha256:07a9dcf735696879406983afc6eba9a787cef7c0cf6c367ca1a5779f036dee74", size = 35170, upload-time = "2026-07-06T18:27:27.767Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/e4/a5573f2c579ca9ad133293bfb624148ba0893674ca4a6eeec85ced9a6a09/pgvector-0.5.0-py3-none-any.whl", hash = "sha256:fedc9800894e6da2be51358d7b7c574bf34f247ca741a5a09513622135f5964f", size = 30958, upload-time = "2026-07-06T18:27:26.797Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psycopg"
|
||||||
|
version = "3.3.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
binary = [
|
||||||
|
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psycopg-binary"
|
||||||
|
version = "3.3.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic"
|
||||||
|
version = "2.13.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "annotated-types" },
|
||||||
|
{ name = "pydantic-core" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic-core"
|
||||||
|
version = "2.46.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic-settings"
|
||||||
|
version = "2.14.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.20.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-asyncio"
|
||||||
|
version = "1.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dotenv"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyyaml"
|
||||||
|
version = "6.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruff"
|
||||||
|
version = "0.15.21"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlalchemy"
|
||||||
|
version = "2.0.51"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "starlette"
|
||||||
|
version = "1.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-inspection"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tzdata"
|
||||||
|
version = "2026.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uvicorn"
|
||||||
|
version = "0.51.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
standard = [
|
||||||
|
{ name = "httptools" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
|
||||||
|
{ name = "watchfiles" },
|
||||||
|
{ name = "websockets" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uvloop"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "watchfiles"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "websockets"
|
||||||
|
version = "16.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8c/02/b9a097e1e16fee4e2fd1ec8c39f6a9c5d6257bae8fa12640caf869f54436/websockets-16.1.tar.gz", hash = "sha256:299468cbe42e2b9981134c7c51d99387d8a7bf562b00183b3eec53f882846dad", size = 182530, upload-time = "2026-07-10T06:32:57.734Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/52/748c014f07f4e0e170c8932de7e647a1511d5ab3049cd978797136aee577/websockets-16.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b6aa3f7ad345cf3862c21f4fbf2ef5e14d911348476c2845e137c091fe3a3f0b", size = 179798, upload-time = "2026-07-10T06:31:09.664Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/5e/2a2e64d977d084e49d37c187c26c056daaff41965be7300cd5dbde6f8b07/websockets-16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b43fcfb521ac2f34ba80b7b8ea16303e4ad82dd8af667bf40839ad3a5d37b164", size = 177478, upload-time = "2026-07-10T06:31:11.072Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/12/5b85b4e75d697e548a94962ce5c036b05dd21cb9545759d555c5586422fc/websockets-16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2bd3e12cd9afbe2baedae0b1eeade8ba64329b60fe2f9abdc966bd10fd2c2ef5", size = 177746, upload-time = "2026-07-10T06:31:12.386Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/62/79b1c8f0cee0da648b4899e1c5b0dbd3aa59846985136a54854db6827ab4/websockets-16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f41979c8623df9bd30d949d82010a8fda5c56ff12cd8508a5b7272b6d4b53a", size = 187345, upload-time = "2026-07-10T06:31:13.754Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/34/b7c5c52c2f24280e1c017acb7ad491a566750a5cceca7f3cf999373bba21/websockets-16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a24d1f35aef07d794a16c853c688e74956c50239bec37b4f2de080056046419b", size = 188581, upload-time = "2026-07-10T06:31:15.075Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/37/604193bebcbeffe96fdf795960b83a15d600880c64dc17ec9c31c5b3427d/websockets-16.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c64c024ddf7a35331b21fcddb562a039c275d2c82e8c2d12939e7da23997270", size = 191362, upload-time = "2026-07-10T06:31:16.395Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/b4/5ee27575b367d7110d4d13945e2a9de067ec84dc71e54b87f01e38550d9a/websockets-16.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e99757f5baafe20fc598e202ea6f5b0b265186ad38d0a17bd8beca16296955", size = 189216, upload-time = "2026-07-10T06:31:17.776Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/22/3e2dcc78d85fc5d9d814895ce6d07d0dfacc0f6aaa1d151f2b8c8d772299/websockets-16.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:353f3bc6e058ac1ccab4b3588e8598837a8c04cfc8351233e6d523be675d844c", size = 187971, upload-time = "2026-07-10T06:31:19.152Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/2f/cd271717b93d5ee19626cb5e38a85baab745c86e33db7c31a3ac729b31b8/websockets-16.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0352f5b38b40e857b6428d468fa21dbb4dd4a567d933c26d9831b4efe1b92f43", size = 185381, upload-time = "2026-07-10T06:31:20.665Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/91/6ad6f2f1426317b5001bd490534208c7360636b35bac1dec2e0c22bfc40e/websockets-16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70bd789afab579602968c39f21cb925466505f3edff22f0ae852bca54978a4f9", size = 188015, upload-time = "2026-07-10T06:31:22.024Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/6d/533733132ab4c07540efd4a8f0b9a435d3a5059b2f26cc476ace1abf7f45/websockets-16.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d0fb4b46f121eccd539353baebd1083a8767a9a351109453d1d1caecd1ba40c2", size = 186619, upload-time = "2026-07-10T06:31:23.376Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/73/16c059f3d73b3331eba10793704afa4faa9939234fb08ef7dca35794e8f0/websockets-16.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c14b6634af01541e4efe2954fd8f263386f7aa6d37c01e55dd8109fd17661452", size = 188497, upload-time = "2026-07-10T06:31:25.024Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/89/9a8fae7dd2acdcfb1a8844c29fe42b518a04b64fce38a0923b6290e452f1/websockets-16.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a58532c49a851bcb481e58c1be23b315c17fe2fbbed509d75aeea12f543d2c15", size = 186051, upload-time = "2026-07-10T06:31:26.291Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/40/b240c7dd6a0e0c59c1f68377cc3015263521080c327c15f5e753c1f6d378/websockets-16.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4e969170c3b08e1d8dabd990fef1fa702c4233aeaabec33f871806e444f6a0e4", size = 187029, upload-time = "2026-07-10T06:31:27.605Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/35/524e3fac40e47d6fdcf6c4b2c95ef1bc8a97e01593c90eff86621df7b716/websockets-16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ff9b000064b88787ba9f7a3cb2af2b68a658ca5aad76458a46469e7124b678a0", size = 187308, upload-time = "2026-07-10T06:31:28.927Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/13/56840cf62c8859af6ba22b9529da937332468c80f32b598753e8a66d3990/websockets-16.1-cp312-cp312-win32.whl", hash = "sha256:b9f5d83f80f4d7c4bba6d97f3755ac05850c784dce0fd2ab371c4e41172f53ff", size = 180161, upload-time = "2026-07-10T06:31:30.316Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/ff/87eb9eb44cb62424a8d729834f2b0515a47e2669fabec29820268f4d50a1/websockets-16.1-cp312-cp312-win_amd64.whl", hash = "sha256:6852c9f653966c16109d3b6f31181fd734f7914927e3f0fa1117af7a18c9aa21", size = 180462, upload-time = "2026-07-10T06:31:31.708Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/58/bd83247f39ddc26ffc2c24eb05087a3b749e00cb4509fc6d19daa23c8495/websockets-16.1-py3-none-any.whl", hash = "sha256:c5149dfe490ec7e5ee5dbf624c642fb725f93a5575c7f00ab594ca9eddb8dd81", size = 174031, upload-time = "2026-07-10T06:32:56.079Z" },
|
||||||
|
]
|
||||||
160
compose.yaml
Normal file
160
compose.yaml
Normal 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
|
||||||
9
data/samples/public/README.md
Normal file
9
data/samples/public/README.md
Normal 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` 策略完成审批绑定。文件中的普通字段不能直接越过云审批。
|
||||||
|
|
||||||
|
这些样例不能用于证明真实地质问答质量;正式评测必须使用取得授权、双人标注并隔离盲测的数据。
|
||||||
20
data/samples/public/demo_documents.jsonl
Normal file
20
data/samples/public/demo_documents.jsonl
Normal 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"}
|
||||||
10
data/samples/public/demo_queries.jsonl
Normal file
10
data/samples/public/demo_queries.jsonl
Normal 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
560
docs/04-project-todo.md
Normal 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,前端产品功能尚未实现 |
|
||||||
|
| 当前预计剩余工期 | 约 9–13 周,含 300 题正式标注、盲测、论文和答辩缓冲 |
|
||||||
|
| 进度权威来源 | 本文的阶段状态、验收证据和已推送提交 |
|
||||||
|
|
||||||
|
本文把 [总体设计](00-overall-design.md)、[数据与评测设计](01-data-and-evaluation.md)、[部署与安全设计](02-deployment-and-security.md) 和 [实施计划](03-implementation-plan.md) 转换为从当前状态到最终答辩、归档的可执行清单。它只表示计划和已验证事实,不因文档完整就声称系统代码已经完成。
|
||||||
|
|
||||||
|
## 1. 状态与完成度规则
|
||||||
|
|
||||||
|
### 1.1 状态定义
|
||||||
|
|
||||||
|
| 状态 | 含义 | 进入条件 | 退出条件 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `DONE` | 阶段已形成可验证闭环 | 功能、测试、文档、提交均完成 | 验收证据齐全且提交已推送 |
|
||||||
|
| `IN_PROGRESS` | 当前正在实施 | 依赖满足并已有明确执行任务 | 达到 `DONE`,或因硬门禁转为 `BLOCKED` |
|
||||||
|
| `TODO` | 尚未开始 | 前置阶段或资源未满足 | 依赖满足并正式启动 |
|
||||||
|
| `BLOCKED` | 硬门禁阻止继续 | 缺授权、密钥、外部服务或必要决策 | 阻断证据消除并记录恢复原因 |
|
||||||
|
| `OPTIONAL` | 不影响首版答辩主链路 | 核心目标已满足且仍有时间 | 完成或明确延期 |
|
||||||
|
|
||||||
|
阶段只有在以下四项同时成立时才能标记 `DONE`:
|
||||||
|
|
||||||
|
1. 范围内功能及失败路径已经实现。
|
||||||
|
2. 适用的自动测试、静态检查和安全检查通过。
|
||||||
|
3. 验收证据可复查,设计与运行文档已同步。
|
||||||
|
4. 符合 Lore 协议的提交已经推送,远端提交可见。
|
||||||
|
|
||||||
|
### 1.2 进度口径
|
||||||
|
|
||||||
|
当前进度采用里程碑权重,不采用“文件数量”或“代码行数”:
|
||||||
|
|
||||||
|
| 阶段 | 权重 | 当前状态 | 已计入进度 |
|
||||||
|
|---|---:|---|---:|
|
||||||
|
| Stage 0 设计与安全基线 | 12% | `DONE` | 12% |
|
||||||
|
| Stage 1 模型与数据库 PoC | 8% | `IN_PROGRESS` | 0% |
|
||||||
|
| Stage 2 完整工程骨架 | 10% | `TODO` | 0% |
|
||||||
|
| Stage 3 数字文档入库 | 14% | `TODO` | 0% |
|
||||||
|
| Stage 4 检索与重排 | 12% | `TODO` | 0% |
|
||||||
|
| Stage 5 生成、引用和 UI | 12% | `TODO` | 0% |
|
||||||
|
| Stage 6 OCR、表格与质量 | 10% | `TODO` | 0% |
|
||||||
|
| Stage 7 正式评测与消融 | 12% | `TODO` | 0% |
|
||||||
|
| Stage 8 安全、恢复、性能和论文 | 7% | `TODO` | 0% |
|
||||||
|
| Stage 9 答辩、发布与归档 | 3% | `TODO` | 0% |
|
||||||
|
| **合计** | **100%** | — | **12%** |
|
||||||
|
|
||||||
|
Stage 1 已完成可离线验收的数据库、适配器契约和 synthetic seed 子闭环,阶段内部约完成 85%;但真实三模型 smoke 仍依赖轮换后的新 Key,因此 Stage 1 不提前计入整体里程碑。本文复选框表示“已通过验收”,不表示“文件是否已经出现”。当前可诚实表述为:**设计基线完成,整体里程碑约 12%,Stage 1 离线部分可运行,真实百炼验证尚未完成。**
|
||||||
|
|
||||||
|
## 2. 阶段依赖与关键路径
|
||||||
|
|
||||||
|
```text
|
||||||
|
Stage 0 DONE
|
||||||
|
-> Stage 1 模型/数据库 PoC
|
||||||
|
-> Stage 2 完整工程骨架
|
||||||
|
-> Stage 3 数字文档入库
|
||||||
|
-> Stage 4 检索与重排
|
||||||
|
-> Stage 6 OCR/表格(可与 Stage 4、5 部分并行)
|
||||||
|
-> Stage 5 生成/引用/UI
|
||||||
|
-> Stage 7 正式评测(标注可在 Stage 3 后提前启动)
|
||||||
|
-> Stage 8 安全/恢复/性能/论文
|
||||||
|
-> Stage 9 答辩/发布/归档
|
||||||
|
```
|
||||||
|
|
||||||
|
| 阶段 | 必须依赖 | 可提前并行的工作 | 不得提前做的工作 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Stage 1 | Stage 0、已轮换 Secret、受控联网 | 虚构数据和 fake server 契约测试 | 使用真实地质资料,或使用已暴露密钥 |
|
||||||
|
| Stage 2 | Stage 1 三模型和迁移 PoC | 前端静态布局、OpenAPI 生成脚本 | 绕过任务租约直接做不可恢复长任务 |
|
||||||
|
| Stage 3 | Stage 2 服务骨架 | 合法语料清单、标注规范准备 | 未通过 `CLOUD_APPROVED` 的内容出域 |
|
||||||
|
| Stage 4 | Stage 3 可追溯切片与向量 | 100 组三元组完善 | 用测试集调相似度、Top K 或阈值 |
|
||||||
|
| Stage 5 | Stage 4 稳定候选和引用 ID | UI 组件、Prompt fake 测试 | 让前端直接接触模型 Key |
|
||||||
|
| Stage 6 | Stage 3 数据状态机 | 与 Stage 4、5 并行开发 | 把图例 OCR 宣称为完整图件理解 |
|
||||||
|
| Stage 7 | Stage 4、5;正式总评需 Stage 6 | Stage 3 后开始问题编写和双审 | 开发者查看盲测答案,或把 Gold 入库 |
|
||||||
|
| Stage 8 | Stage 2–7 主链路稳定 | 论文各章随阶段持续写作 | 未恢复验证就声称备份可用 |
|
||||||
|
| Stage 9 | Stage 8 发布候选通过 | 演讲稿和演示素材提前准备 | 在最终演示前临时升级依赖或模型配置 |
|
||||||
|
|
||||||
|
## 3. Stage 0:仓库、安全和设计基线
|
||||||
|
|
||||||
|
- **状态:** `DONE`
|
||||||
|
- **工期:** 已完成
|
||||||
|
- **依赖:** 无
|
||||||
|
|
||||||
|
### 已完成任务
|
||||||
|
|
||||||
|
- [x] 建立仓库说明、贡献约定、安全说明和 AGENTS 约束。
|
||||||
|
- [x] 建立 `backend/`、`frontend/`、`data/`、`ops/`、`scripts/` 和 `docs/` 结构。
|
||||||
|
- [x] 完成 00–03 总体、数据评测、部署安全和实施计划设计基线。
|
||||||
|
- [x] 建立 ADR 入口和首批关键架构决策。
|
||||||
|
- [x] 建立 `.gitignore`、本地 Secret 扫描钩子和 Gitea 工作流基线。
|
||||||
|
- [x] 建立 `make verify-design`、Markdown 相对链接和 diff 检查。
|
||||||
|
- [x] 将 Stage 0 基线提交推送到 `origin/main`。
|
||||||
|
|
||||||
|
### 验收证据
|
||||||
|
|
||||||
|
- `git status --short --branch` 显示 `main...origin/main`,Stage 0 基线提交远端可见。
|
||||||
|
- `make verify-design` 可检查 Secret、设计文档、相对链接和 diff。
|
||||||
|
- [00-overall-design.md](00-overall-design.md) 明确状态为“设计基线,待实现验证”。
|
||||||
|
- 当前不存在可运行应用,因此 Stage 0 的完成只代表设计和工程防线完成。
|
||||||
|
|
||||||
|
### 已完成提交/推送节点
|
||||||
|
|
||||||
|
- [x] `S0-A`:建立安全、架构、数据、部署和实施设计基线。
|
||||||
|
- [x] 推送 `main` 并确认 `origin/main` 指向 Stage 0 基线提交。
|
||||||
|
|
||||||
|
### 后续仍持续生效的门禁
|
||||||
|
|
||||||
|
- 真实 Key、真实工作空间地址、受限数据和私有 benchmark 永不进入 Git。
|
||||||
|
- 新实现若改变边界、数据状态或模型契约,必须先更新 ADR/设计再验收。
|
||||||
|
- Stage 0 完成不能被用于宣称模型、数据库、后端或前端已经可运行。
|
||||||
|
|
||||||
|
## 4. Stage 1:模型与数据库 PoC
|
||||||
|
|
||||||
|
- **状态:** `IN_PROGRESS`
|
||||||
|
- **预计工期:** 1–2 个工作日;外部密钥或网络未就绪时暂停计时
|
||||||
|
- **依赖:** Stage 0;已轮换百炼 Key;允许的工作空间端点;本机 Docker
|
||||||
|
|
||||||
|
### 具体实现任务
|
||||||
|
|
||||||
|
#### 4.1 安全与供应商前置
|
||||||
|
|
||||||
|
- [ ] 在百炼控制台撤销聊天中曾暴露的旧 Key,创建新 Key。
|
||||||
|
- [ ] 通过 Docker Secret 或未提交的本地 Secret 文件注入新 Key;禁止写入 `.env.example`。
|
||||||
|
- [ ] 分别确认 Embedding、Rerank 和 Chat 的实际路径、请求协议、模型返回名和能力指纹。
|
||||||
|
- [ ] 记录脱敏的 endpoint identity hash、region、resolved revision 或受控 cache epoch。
|
||||||
|
- [x] 建立 live provider 测试的手动开关;默认 CI 只使用 fake server。
|
||||||
|
|
||||||
|
#### 4.2 最小后端和 Compose
|
||||||
|
|
||||||
|
- [x] 创建 `backend/pyproject.toml` 和依赖锁文件,不引入未说明依赖。
|
||||||
|
- [x] 创建最小 `backend/Dockerfile`,使用非 root 用户和固定基础镜像 digest。
|
||||||
|
- [x] 创建最小 `compose.yaml`,包含 `db + migrate + provider-smoke + seed-demo/offline`。
|
||||||
|
- [x] 创建 PostgreSQL bootstrap、migrator 和 app 分权角色初始化脚本;备份只读角色在备份功能落地时单独创建。
|
||||||
|
- [x] 创建 Alembic 基线迁移,启用 pgvector 并建立 1024 维向量表/HNSW 基线。
|
||||||
|
- [x] 确保运行期服务不挂载 bootstrap 或 migrator Secret。
|
||||||
|
|
||||||
|
#### 4.3 三模型适配器
|
||||||
|
|
||||||
|
- [x] 实现 `EmbeddingProvider.embed_documents` 和 `embed_query` 契约。
|
||||||
|
- [x] 通过 MockTransport 验证 `text-embedding-v4` 的 1024 维、批次下标和有限浮点数;真实响应待 smoke。
|
||||||
|
- [ ] 对原生 Embedding 模式区分 `document/query`;兼容模式能力必须显式记录。
|
||||||
|
- [x] 实现 `qwen3-rerank` 候选索引回填、Token 预检、Top 10 保存。
|
||||||
|
- [x] 实现 `deepseek-v4-flash` 最小流式 Chat,默认关闭搜索和思考。
|
||||||
|
- [x] 为三个适配器实现 timeout、429/5xx 有界重试和脱敏错误映射。
|
||||||
|
|
||||||
|
#### 4.4 虚构数据和测试
|
||||||
|
|
||||||
|
- [x] 创建 `provider_smoke`,只输出状态、模型、耗时、request ID 和脱敏错误码。
|
||||||
|
- [x] 创建 `seed-demo`,使用 20 条明确标记的虚构文本完成写入、检索和重排。
|
||||||
|
- [x] 重复运行 seed,证明行数和任务不增长。
|
||||||
|
- [x] 建立 fake server 契约测试,覆盖成功、乱序、无效响应、401、429、5xx 和超时。
|
||||||
|
- [x] 建立并执行迁移空 volume、角色权限和 Docker 重启持久化测试。
|
||||||
|
- [x] 明确文档族 split、盲测保管人和“盲测用于调参即退役”规则。
|
||||||
|
- [x] 扩展 `make verify`,纳入 Stage 1 后端格式、类型和测试门禁。
|
||||||
|
|
||||||
|
### 验收证据
|
||||||
|
|
||||||
|
- [x] `docker compose up db migrate` 在空 volume 成功建立扩展、角色和表。
|
||||||
|
- [ ] `docker compose run --rm provider-smoke` 三模型探测成功,日志无 Secret。
|
||||||
|
- [x] `docker compose run --rm seed-demo-offline` 完成 20 条虚构数据写入、检索和重排;真实模式待新 Key。
|
||||||
|
- [x] 第二次运行 seed 后数据库计数与第一次相同,均为 chunks/vectors/searchable = 20/20/20。
|
||||||
|
- [x] 41 项测试证明 Embedding 维度/下标、Rerank 下标、Chat 流式响应和失败路径正确。
|
||||||
|
- [x] `make verify`、Secret 扫描、固定镜像构建和 `git diff --check` 通过。
|
||||||
|
|
||||||
|
### 2026-07-12 已验证运行证据
|
||||||
|
|
||||||
|
- 全新 volume 上 `db` 达到 healthy,`migrate` 以非超级用户退出码 0,版本为 `0001_initial_schema`。
|
||||||
|
- `vector` 扩展与 HNSW 基线存在;app/migrator 均非超级用户,app 无 `rag` schema DDL 权限但具有所需 DML。
|
||||||
|
- 两次离线 seed 均输出 20/20/20,9 个可回答虚构问题 Hit@3 = 9/9。
|
||||||
|
- PostgreSQL 重启后计数仍为 20/20/20。
|
||||||
|
- app 尝试修改已 `CLOUD_APPROVED` 的 `cloud_text` 被审批不可变触发器拒绝;尝试建表被权限拒绝。
|
||||||
|
- 唯一未验收项是真实 `text-embedding-v4` / `qwen3-rerank` / `deepseek-v4-flash` smoke 与真实模式 seed。
|
||||||
|
|
||||||
|
### 提交/推送节点
|
||||||
|
|
||||||
|
- [ ] `S1-A`:数据库初始化、分权角色、迁移和最小 Compose;验证后提交并推送。
|
||||||
|
- [ ] `S1-B`:三模型适配器、fake 契约测试和 live smoke;验证后提交并推送。
|
||||||
|
- [ ] `S1-C`:20 条虚构 seed、幂等验证、运行证据和文档同步;验证后提交并推送。
|
||||||
|
|
||||||
|
建议最终阶段提交意图:`Prove the selected model chain before building product workflows`。
|
||||||
|
|
||||||
|
### 风险门禁
|
||||||
|
|
||||||
|
- **停止条件:** 新 Key 未轮换、Secret 出现在日志/镜像、真实端点能力不明、迁移使用超级用户运行。
|
||||||
|
- **禁止数据:** Stage 1 只使用虚构文本,不上传任何真实地质报告。
|
||||||
|
- **降级策略:** 供应商暂不可用时完成 fake 契约和数据库工作,但 Stage 1 不得标记 `DONE`。
|
||||||
|
- **进入 Stage 2 的门槛:** 三模型真实 smoke、迁移和 seed 幂等全部通过。
|
||||||
|
|
||||||
|
## 5. Stage 2:扩展为完整工程骨架
|
||||||
|
|
||||||
|
- **状态:** `TODO`
|
||||||
|
- **预计工期:** 3–4 个工作日
|
||||||
|
- **依赖:** Stage 1 `DONE`
|
||||||
|
|
||||||
|
### 具体实现任务
|
||||||
|
|
||||||
|
- [ ] 建立 FastAPI 应用工厂、配置加载、稳定错误码和 OpenAPI。
|
||||||
|
- [ ] 按 `domain -> ports -> services -> adapters/persistence` 落实后端依赖边界。
|
||||||
|
- [ ] 建立 SQLAlchemy 仓储、Alembic 迁移运行器和事务边界。
|
||||||
|
- [ ] 建立 PostgreSQL `background_jobs`、租约、lease token、fencing 和重试状态机。
|
||||||
|
- [ ] 建立 Worker maintenance loop、advisory-lock reaper 和强杀恢复测试。
|
||||||
|
- [ ] 建立 `/health/live`、`/health/ready` 和管理员模型能力探测端点。
|
||||||
|
- [ ] 建立 React + TypeScript strict、路由、TanStack Query 和 OpenAPI 客户端生成。
|
||||||
|
- [ ] 建立 Nginx,仅发布 Web 端口;API、Worker、DB 只在内部网络。
|
||||||
|
- [ ] 建立完整 Compose:`web/api/worker/db/migrate`,OCR profile 默认关闭。
|
||||||
|
- [ ] 建立 Ruff、类型、pytest、ESLint、TypeScript、Vitest 和镜像检查。
|
||||||
|
- [ ] 建立 Gitea CI 的 fake provider 测试;外部 PR 不运行真实 Key 测试。
|
||||||
|
|
||||||
|
### 验收证据
|
||||||
|
|
||||||
|
- [ ] 空白机器仅按 README 执行即可启动完整骨架。
|
||||||
|
- [ ] migration 只执行一次,失败时 API readiness 不通过。
|
||||||
|
- [ ] `docker compose ps` 只暴露 Nginx/Web 端口。
|
||||||
|
- [ ] Docker 重启后数据库数据保留。
|
||||||
|
- [ ] 强杀 Worker 后验证可重领、耗尽进入 `FAILED`、旧 token 不能提交。
|
||||||
|
- [ ] 后端/前端 lint、类型、单元和集成测试均通过。
|
||||||
|
|
||||||
|
### 提交/推送节点
|
||||||
|
|
||||||
|
- [ ] `S2-A`:FastAPI、持久化、健康端点和错误契约。
|
||||||
|
- [ ] `S2-B`:任务租约、reaper、fencing 和恢复测试。
|
||||||
|
- [ ] `S2-C`:React、Nginx、完整 Compose、CI 和文档。
|
||||||
|
- [ ] 每个节点验证后独立提交并推送;Stage 2 完成时在本文更新状态。
|
||||||
|
|
||||||
|
### 风险门禁
|
||||||
|
|
||||||
|
- 不为单机毕设提前引入 Redis、Celery、微服务或独立向量数据库。
|
||||||
|
- readiness 不把百炼短时故障等同于 API 容器不可用;模型健康单独展示。
|
||||||
|
- 前端构建产物、OpenAPI 示例和测试日志不得包含 Key 或真实受限数据。
|
||||||
|
|
||||||
|
## 6. Stage 3:数字文档入库主链路
|
||||||
|
|
||||||
|
- **状态:** `TODO`
|
||||||
|
- **预计工期:** 1 周
|
||||||
|
- **依赖:** Stage 2 `DONE`;合法数字资料样本和审核人可用
|
||||||
|
|
||||||
|
### 具体实现任务
|
||||||
|
|
||||||
|
- [ ] 实现上传流式大小、扩展名、MIME、页数、加密和恶意文件检查。
|
||||||
|
- [ ] 实现 `QUARANTINED_LOCAL_REVIEW -> LOCAL_PARSED_PENDING_CLOUD_REVIEW -> CLOUD_APPROVED/REJECTED` 状态机。
|
||||||
|
- [ ] 在隔离本地完成数字 PDF/DOCX/TXT/Markdown 解析、页码和章节恢复。
|
||||||
|
- [ ] 生成 `raw_text`、脱敏 `cloud_text` 和 outbound manifest;所有外发调用前核对哈希。
|
||||||
|
- [ ] 实现 512 Token 基线切分、64 Token 重叠、表头上下文和元数据前缀开关。
|
||||||
|
- [ ] 实现精确去重、近重复族、版本关系和确定性 chunk ID。
|
||||||
|
- [ ] 实现 Embedding 批次、内容缓存、chunk assignment、检查点和有界重试。
|
||||||
|
- [ ] 实现 `documents.active_version_id` 与 `chunks.searchable` 单事务切换和持续对账。
|
||||||
|
- [ ] 实现删除立即退出检索、后台物理清理和最小审计记录。
|
||||||
|
- [ ] 实现知识库、文档列表、上传进度、复核状态和错误详情 UI。
|
||||||
|
- [ ] 冻结 corpus manifest 与文档族 60/20/20 split;盲测标签由独立保管人控制。
|
||||||
|
- [ ] 用允许使用的小规模数字 PDF 语料完成端到端入库演练。
|
||||||
|
|
||||||
|
### 验收证据
|
||||||
|
|
||||||
|
- [ ] 100% `searchable` 切片可追溯当前文档版本、页码和 outbound manifest。
|
||||||
|
- [ ] `approved chunks == READY vector assignments`,维度和文本/profile 哈希一致。
|
||||||
|
- [ ] 同一文件重复上传、任务重跑和中断恢复不产生重复记录或重复费用。
|
||||||
|
- [ ] 新版本未完整 READY 前旧版本继续可检索;切换没有空窗。
|
||||||
|
- [ ] 删除后在线查询立即不可命中,物理清理可审计。
|
||||||
|
- [ ] 未获云审批的文档不会触发 Embedding、Rerank 或 LLM 请求。
|
||||||
|
|
||||||
|
### 提交/推送节点
|
||||||
|
|
||||||
|
- [ ] `S3-A`:上传、数据治理状态机和本地解析。
|
||||||
|
- [ ] `S3-B`:结构切分、去重、向量任务和版本激活。
|
||||||
|
- [ ] `S3-C`:文档 UI、删除/恢复测试、corpus manifest 和证据。
|
||||||
|
- [ ] 每个节点通过安全与回归门禁后提交、推送。
|
||||||
|
|
||||||
|
### 风险门禁
|
||||||
|
|
||||||
|
- 权利、涉密等级、云处理权限任一不明确即停止外发。
|
||||||
|
- `cloud_text`、处理器或 manifest 任一变化自动撤销原审批。
|
||||||
|
- 解析失败页、跨页表和低置信内容必须显式失败或进入复核,不静默丢弃。
|
||||||
|
|
||||||
|
## 7. Stage 4:检索与重排
|
||||||
|
|
||||||
|
- **状态:** `TODO`
|
||||||
|
- **预计工期:** 1 周
|
||||||
|
- **依赖:** Stage 3 `DONE`;开发集 qrels/100 组三元组可用
|
||||||
|
|
||||||
|
### 具体实现任务
|
||||||
|
|
||||||
|
- [ ] 实现 query embedding,并在原生模式使用 `text_type=query`。
|
||||||
|
- [ ] 实现同权限过滤条件下的 exact Cosine Top K 基线。
|
||||||
|
- [ ] 实现 HNSW、iterative scan、`ef_search`、over-fetch 和 ANN 对账。
|
||||||
|
- [ ] 在候选生成 SQL 内应用知识库、角色、文档状态和可见范围过滤。
|
||||||
|
- [ ] 实现 Dense Top 50、`qwen3-rerank` Top 10、生成上下文 Top 8。
|
||||||
|
- [ ] 实现 Rerank 总 Token 预检、响应 index 回填、裁剪和错误分类。
|
||||||
|
- [ ] 实现候选去重、邻接补全和上下文 Token 预算。
|
||||||
|
- [ ] 实现检索实验室:展示查询配置、初召回、重排、来源和耗时。
|
||||||
|
- [ ] 完善 100 组三元组,覆盖同义词、实体冲突、数字、单位、OCR 和难负例。
|
||||||
|
- [ ] 校准 Top K 和阈值;所有参数只使用开发集。
|
||||||
|
|
||||||
|
### 验收证据
|
||||||
|
|
||||||
|
- [ ] 三元组 `sim(query, positive) > sim(query, hard_negative)` 达到预设目标或形成完整错误报告。
|
||||||
|
- [ ] 至少 200 个查询的 ANN Recall@10 相对 exact 基线达到目标,过滤条件完全相同。
|
||||||
|
- [ ] 未授权文档从候选生成阶段即被排除。
|
||||||
|
- [ ] Rerank 前后 MRR/nDCG、延迟和 Token 可复查。
|
||||||
|
- [ ] 相同运行配置可由 `config.yaml` 和 provider profile 重现。
|
||||||
|
|
||||||
|
### 提交/推送节点
|
||||||
|
|
||||||
|
- [ ] `S4-A`:exact/HNSW、权限过滤和 ANN 质量测试。
|
||||||
|
- [ ] `S4-B`:Rerank、上下文构建和错误处理。
|
||||||
|
- [ ] `S4-C`:检索实验室、三元组报告和参数冻结。
|
||||||
|
|
||||||
|
### 风险门禁
|
||||||
|
|
||||||
|
- 不使用一个固定余弦或 Rerank 分数跨所有问题通用。
|
||||||
|
- Rerank 不能恢复初召回未命中的证据,错误归因必须分层。
|
||||||
|
- 不得查看盲测答案后调整维度、Top K、阈值或 instruct。
|
||||||
|
|
||||||
|
## 8. Stage 5:生成、引用和问答 UI
|
||||||
|
|
||||||
|
- **状态:** `TODO`
|
||||||
|
- **预计工期:** 1 周
|
||||||
|
- **依赖:** Stage 4 `DONE`
|
||||||
|
|
||||||
|
### 具体实现任务
|
||||||
|
|
||||||
|
- [ ] 建立版本化 Grounded Answer Prompt,要求仅依据编号证据回答。
|
||||||
|
- [ ] 实现 `deepseek-v4-flash` SSE、取消传播、超时和流式错误事件。
|
||||||
|
- [ ] 后端将模型引用编号映射到真实 chunk/doc/page,拒绝模型自造引用。
|
||||||
|
- [ ] 实现证据不足拒答、模型失败时保留检索结果、资料冲突分来源陈述。
|
||||||
|
- [ ] 实现会话与消息持久化、来源卡片、页码定位和用户反馈。
|
||||||
|
- [ ] 实现文档管理、聊天、检索实验室、评测和系统状态页面导航。
|
||||||
|
- [ ] 清理 Markdown/HTML,验证 XSS、危险链接和 Prompt Injection 回归。
|
||||||
|
- [ ] 测试用户取消后 Embedding/Rerank/Chat 下游请求停止。
|
||||||
|
- [ ] 记录 trace、阶段耗时、Token、request ID 和脱敏错误。
|
||||||
|
|
||||||
|
### 验收证据
|
||||||
|
|
||||||
|
- [ ] 回答中的每个引用均可打开到授权文档和正确页码。
|
||||||
|
- [ ] 模型伪造、越界或不存在的引用被后端拦截。
|
||||||
|
- [ ] 无答案题正确拒答;系统错误与“证据不足”状态不同。
|
||||||
|
- [ ] 模型不可用时仍展示检索来源和可理解错误。
|
||||||
|
- [ ] Playwright 覆盖上传、问答、引用、取消、失败重试和 XSS。
|
||||||
|
|
||||||
|
### 提交/推送节点
|
||||||
|
|
||||||
|
- [ ] `S5-A`:生成服务、证据 Prompt、引用验证和拒答。
|
||||||
|
- [ ] `S5-B`:聊天/来源 UI、SSE 取消和错误状态。
|
||||||
|
- [ ] `S5-C`:端到端、安全回归、可观测性和文档。
|
||||||
|
|
||||||
|
### 风险门禁
|
||||||
|
|
||||||
|
- 模型输出永远视为不可信数据;引用和 Markdown 必须后端/前端双层校验。
|
||||||
|
- 前端不得获得模型 Key、工作空间凭证或未授权文档正文。
|
||||||
|
- 不把问答系统输出表述为勘查结论、储量评估或投资建议。
|
||||||
|
|
||||||
|
## 9. Stage 6:OCR、表格和数据质量挑战集
|
||||||
|
|
||||||
|
- **状态:** `TODO`
|
||||||
|
- **预计工期:** 1–2 周
|
||||||
|
- **依赖:** Stage 3 `DONE`;可与 Stage 4、5 部分并行
|
||||||
|
|
||||||
|
### 具体实现任务
|
||||||
|
|
||||||
|
- [ ] 建立页级数字/扫描/混合 PDF 检测。
|
||||||
|
- [ ] 建立隔离的 OCR worker profile、资源限制和临时文件清理。
|
||||||
|
- [ ] 实现 300 DPI 渲染、旋转/倾斜校正、PaddleOCR/PP-Structure 输出。
|
||||||
|
- [ ] 保留 `raw_text`、修订文本、bbox、置信度、修订人和处理器 profile。
|
||||||
|
- [ ] 建立低置信页、数值密集页、表格页人工复核队列。
|
||||||
|
- [ ] 提取表格 JSON、Markdown、截图引用和逐行语义文本。
|
||||||
|
- [ ] 处理跨页表、合并单元格、表头、单位和脚注;无法确认时显式失败。
|
||||||
|
- [ ] 建立 OCR 金标准页、术语/数字/单位和表格行列对齐评测。
|
||||||
|
- [ ] 建立图件挑战集和 UI 边界提示,不宣称已理解空间几何。
|
||||||
|
|
||||||
|
### 验收证据
|
||||||
|
|
||||||
|
- [ ] 金标准页 CER ≤ 3%,地质术语准确率 ≥ 95%,数字/符号/单位准确率 ≥ 98%。
|
||||||
|
- [ ] 表格行列对齐 ≥ 97%,数值及单位准确率 ≥ 99%。
|
||||||
|
- [ ] OCR 原文和人工修订文本均可追溯且不会互相覆盖。
|
||||||
|
- [ ] 跨页表/合并单元格失败可见,不产生静默错位。
|
||||||
|
- [ ] 只依赖图件空间关系的问题被标记为挑战集或拒答。
|
||||||
|
|
||||||
|
### 提交/推送节点
|
||||||
|
|
||||||
|
- [ ] `S6-A`:扫描检测、OCR worker 和复核队列。
|
||||||
|
- [ ] `S6-B`:表格结构、语义行和数值校验。
|
||||||
|
- [ ] `S6-C`:金标准质量报告、图件边界和文档。
|
||||||
|
|
||||||
|
### 风险门禁
|
||||||
|
|
||||||
|
- OCR/表格完整处理必须在出域审批前于隔离环境完成。
|
||||||
|
- 低置信结果不得自动变为 `searchable`。
|
||||||
|
- OCR 模型、预处理或修订变化后,outbound manifest 和向量审批必须失效重做。
|
||||||
|
|
||||||
|
## 10. Stage 7:正式验证集、盲测与消融
|
||||||
|
|
||||||
|
- **状态:** `TODO`
|
||||||
|
- **预计工期:** 3–4 周;语料标注可在 Stage 3 后提前并行
|
||||||
|
- **依赖:** 问题编写依赖 Stage 3;最终评测依赖 Stage 4–6 `DONE`
|
||||||
|
|
||||||
|
### 具体实现任务
|
||||||
|
|
||||||
|
- [ ] 建立至少 300 题:构建、开发、盲测各 100;500 题仅作为资源充足时扩展。
|
||||||
|
- [ ] 按文档族、来源、矿种、题型、模态、难度和多跳分层。
|
||||||
|
- [ ] 为 Gold 和全部 0–3 judgment 建立不可变源锚点。
|
||||||
|
- [ ] 双人独立审核答案、证据和可回答性;第三名地质评审裁决。
|
||||||
|
- [ ] 建立 `anchored_judgments.jsonl`、manifest、版本和 SHA-256。
|
||||||
|
- [ ] 使用 Dense/词法/Hybrid/Rerank union pooling;未判定结果先补标再计分。
|
||||||
|
- [ ] 每个 run 生成独立 qrels、anchor resolution、预测、指标和错误报告。
|
||||||
|
- [ ] 实现 Hit、CompleteHit、EvidenceGroupRecall、MRR、nDCG、专家答案、忠实度、引用和拒答指标。
|
||||||
|
- [ ] 对查询做 1000 次 bootstrap,输出 95% 置信区间和分层结果。
|
||||||
|
- [ ] 在开发集完成维度、切片、重叠、前缀、Rerank、上下文、Query 改写等消融。
|
||||||
|
- [ ] 冻结最终配置、corpus、judgment set 和盲测哈希,再执行一次正式盲测。
|
||||||
|
- [ ] 补标或裁决变化后生成新 judgment 版本,并对比较表中全部系统重新计分。
|
||||||
|
- [ ] 生成论文图表、成本/延迟对比和错误分类案例。
|
||||||
|
|
||||||
|
### 验收证据
|
||||||
|
|
||||||
|
- [ ] Gold、参考答案、qrels、`eval/` 和 `runs/` 永不被知识库导入器扫描。
|
||||||
|
- [ ] 正式盲测标签在参数冻结前对开发者不可见。
|
||||||
|
- [ ] 检索、重排、生成、引用、拒答和端到端指标均有版本化输入与配置。
|
||||||
|
- [ ] 所有比较系统使用相同 judgment snapshot、resolver 和 corpus manifest。
|
||||||
|
- [ ] 所有论文实验图表可由冻结运行产物重新生成。
|
||||||
|
- [ ] 若盲测结果用于调参,该集合立即退役,最终结论改用新盲测集。
|
||||||
|
|
||||||
|
### 提交/推送节点
|
||||||
|
|
||||||
|
- [ ] `S7-A`:问题 schema、源锚点、标注工具和构建/开发集。
|
||||||
|
- [ ] `S7-B`:盲测托管、union pooling、评测 runner 和评分规约。
|
||||||
|
- [ ] `S7-C`:消融、配置冻结和最终盲测。
|
||||||
|
- [ ] `S7-D`:图表、错误分析、成本和论文实验章节。
|
||||||
|
|
||||||
|
### 风险门禁
|
||||||
|
|
||||||
|
- 盲测问题、答案和标签必须由独立保管人/受控凭据管理。
|
||||||
|
- 公开 benchmark 只能包含允许再分发或充分脱敏的内容。
|
||||||
|
- LLM 可以辅助出题或评分,但不能同时成为唯一出题者、唯一裁判和最终评分者。
|
||||||
|
- 工期不足时缩减到 300 题,不降低盲测隔离、双审和可复现要求。
|
||||||
|
|
||||||
|
## 11. Stage 8:安全加固、恢复、性能和论文定稿
|
||||||
|
|
||||||
|
- **状态:** `TODO`
|
||||||
|
- **预计工期:** 1 周,论文写作应贯穿前序阶段
|
||||||
|
- **依赖:** Stage 2–7 主链路和评测结果稳定
|
||||||
|
|
||||||
|
### 具体实现任务
|
||||||
|
|
||||||
|
- [ ] 完成管理员、资料员、评测人员和普通用户 RBAC。
|
||||||
|
- [ ] 完成对象级知识库/文档权限、审计日志和越权回归。
|
||||||
|
- [ ] 完成上传限制、路径穿越、压缩炸弹、XSS、Prompt Injection 和限流测试。
|
||||||
|
- [ ] 完成结构化日志脱敏、trace、模型 Token/耗时、任务和索引监控。
|
||||||
|
- [ ] 固定镜像 digest、依赖锁和数据库迁移版本。
|
||||||
|
- [ ] 建立数据库、允许备份文件和独立 Secret 的备份策略。
|
||||||
|
- [ ] 在全新 Compose project 恢复数据库与文件,重建或校验索引。
|
||||||
|
- [ ] 恢复后运行固定 20 题冒烟并记录 RTO、缺口和哈希。
|
||||||
|
- [ ] 测量入库吞吐、查询 p50/p95、并发、错误率和每 1000 查询费用。
|
||||||
|
- [ ] 完成论文各章、系统截图、实验图表、失败案例和局限性。
|
||||||
|
- [ ] 完成部署手册、管理员手册、演示故障降级和恢复手册。
|
||||||
|
|
||||||
|
### 验收证据
|
||||||
|
|
||||||
|
- [ ] `make verify`、Secret 扫描、镜像检查、单元/集成/契约/E2E/安全测试通过。
|
||||||
|
- [ ] 空白环境恢复成功,并通过固定 20 题冒烟。
|
||||||
|
- [ ] 端到端 p95、稳态错误率、并发和成本均为实测结果。
|
||||||
|
- [ ] 论文中的每张实验图表可追溯到 run manifest。
|
||||||
|
- [ ] 论文明确系统边界,不夸大图件理解、找矿预测或专业决策能力。
|
||||||
|
|
||||||
|
### 提交/推送节点
|
||||||
|
|
||||||
|
- [ ] `S8-A`:RBAC、审计、安全和限流。
|
||||||
|
- [ ] `S8-B`:备份、恢复、性能和费用报告。
|
||||||
|
- [ ] `S8-C`:论文定稿、运维手册和 release candidate。
|
||||||
|
|
||||||
|
### 风险门禁
|
||||||
|
|
||||||
|
- 只有备份文件、没有空环境恢复证据,不得通过验收。
|
||||||
|
- 性能数字必须注明硬件、网络、数据规模、模型配置和采样窗口。
|
||||||
|
- release candidate 后不再做非必要依赖升级或架构替换。
|
||||||
|
|
||||||
|
## 12. Stage 9:答辩、发布与归档
|
||||||
|
|
||||||
|
- **状态:** `TODO`
|
||||||
|
- **预计工期:** 2–3 个工作日,另预留答辩排期缓冲
|
||||||
|
- **依赖:** Stage 8 release candidate `DONE`
|
||||||
|
|
||||||
|
### 具体实现任务
|
||||||
|
|
||||||
|
- [ ] 冻结答辩 release candidate、镜像 digest、迁移、模型配置和演示数据。
|
||||||
|
- [ ] 在干净机器完整执行 `docker compose up -d --build` 和健康检查。
|
||||||
|
- [ ] 按 [实施计划的答辩脚本](03-implementation-plan.md#8-最终答辩演示脚本) 连续彩排至少两次。
|
||||||
|
- [ ] 准备模型临时不可用时的降级演示、预录视频和静态证据。
|
||||||
|
- [ ] 检查答辩 PPT、论文、截图、日志和视频中没有 Secret 或受限原文。
|
||||||
|
- [ ] 准备演示问题:正常问答、无答案、版本冲突、表格数值和局限性。
|
||||||
|
- [ ] 生成公开归档包与内部受控归档包;两者使用不同 manifest。
|
||||||
|
- [ ] 归档源代码、迁移、锁文件、镜像 digest、公开样例、运行配置、论文和演示材料。
|
||||||
|
- [ ] 归档数据库/文件恢复说明、校验和、保留周期和销毁策略。
|
||||||
|
- [ ] 删除临时 Secret、临时文件和未授权数据;按策略轮换或撤销演示 Key。
|
||||||
|
- [ ] 创建最终 Git tag/release,推送远端并记录 commit、tag 和归档 SHA-256。
|
||||||
|
- [ ] 记录已知限制、未完成 OPTIONAL 项、后续维护人和恢复入口。
|
||||||
|
|
||||||
|
### 验收证据
|
||||||
|
|
||||||
|
- [ ] 干净环境演示全流程成功,或按预定降级方案完成而不泄漏数据。
|
||||||
|
- [ ] 答辩十步脚本中的每一步都有实时或冻结证据。
|
||||||
|
- [ ] 公开包通过 Secret、版权、坐标和商业秘密复核。
|
||||||
|
- [ ] 内部包访问控制、保留期、销毁责任人和恢复步骤明确。
|
||||||
|
- [ ] 最终 tag/release、论文版本、镜像 digest、run manifest 和归档哈希相互可追踪。
|
||||||
|
|
||||||
|
### 提交/推送节点
|
||||||
|
|
||||||
|
- [ ] `S9-A`:答辩 release candidate、演示脚本和故障降级证据。
|
||||||
|
- [ ] `S9-B`:论文/材料最终修订、公开与内部归档 manifest。
|
||||||
|
- [ ] `S9-C`:最终 tag/release、远端推送和归档校验记录。
|
||||||
|
|
||||||
|
### 风险门禁
|
||||||
|
|
||||||
|
- 答辩当天不临时升级模型、依赖、数据库或重新生成整套索引。
|
||||||
|
- 公开 release 不包含真实 Key、工作空间地址、受限语料、私有 benchmark 或可逆脱敏内容。
|
||||||
|
- 未验证恢复的备份、未固定哈希的实验和未推送提交不能作为最终归档证据。
|
||||||
|
|
||||||
|
## 13. 跨阶段风险登记与停止条件
|
||||||
|
|
||||||
|
| 风险 | 等级 | 触发信号 | 必须动作 | 解除门槛 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 已暴露或疑似暴露 Key | 严重 | Key 出现在聊天、日志、Git、镜像 | 立即停止真实调用并轮换;运行 Secret 扫描 | 新 Key 仅由 Secret 注入,旧 Key 已撤销 |
|
||||||
|
| 未授权/涉密资料出域 | 严重 | 许可、坐标、保密等级或云处理权不清 | 保持隔离,不调用任何云模型 | 审核 exact outbound manifest 并进入 `CLOUD_APPROVED` |
|
||||||
|
| Gold/盲测泄漏 | 严重 | `eval/` 被入库、开发者看到盲测标签 | 停止评测,作废受污染集合 | 新盲测集、独立保管和导入 allowlist 测试通过 |
|
||||||
|
| 模型别名漂移 | 高 | 能力指纹或响应模型变化 | 提升 cache epoch,冻结新 profile,重跑受影响实验 | 新旧运行明确分版,比较不混用 |
|
||||||
|
| 向量/版本不一致 | 高 | 文本哈希、profile、维度或 active 投影不一致 | 阻止激活并运行 reconciliation | 新版本全部 READY,原子切换验证通过 |
|
||||||
|
| OCR/表格静默错误 | 高 | 数值、单位、行列错位或低置信未处理 | 降级为复核/失败,不进入检索 | 金标准和人工复核门槛通过 |
|
||||||
|
| 供应商不可用/限流 | 中 | 429、5xx、超时或区域故障 | 有界重试、熔断、展示检索降级 | smoke 恢复且错误率正常 |
|
||||||
|
| 标注工期失控 | 高 | 3–4 周内无法完成 500 题 | 缩减到 300 题,保持双审与盲测 | 300 题各 split/题型覆盖满足规范 |
|
||||||
|
| 范围蔓延 | 中 | 提前引入微服务、多模态预测或新数据库 | 回到首版非目标,记录 ADR | 核心主链路和论文证据完成后再评估 |
|
||||||
|
| 答辩环境不稳定 | 高 | 干净机器失败、模型网络不稳 | 冻结 RC、准备预录和离线证据 | 连续两次彩排通过且有降级路径 |
|
||||||
|
|
||||||
|
## 14. 每阶段统一收尾清单
|
||||||
|
|
||||||
|
每个阶段完成前复制并勾选以下清单:
|
||||||
|
|
||||||
|
- [ ] 实现范围与本阶段 TODO 一致,未静默扩展权限、依赖或服务。
|
||||||
|
- [ ] 正常路径、失败路径和恢复路径均有验证。
|
||||||
|
- [ ] Ruff/类型/pytest、ESLint/TypeScript/Vitest/Playwright 等适用检查通过。
|
||||||
|
- [ ] `make verify`、Secret 扫描、Markdown 链接和 diff 检查通过。
|
||||||
|
- [ ] 数据库迁移可在空环境向前应用,必要时有回滚/恢复说明。
|
||||||
|
- [ ] API、配置、数据格式、ADR 和本文状态已同步。
|
||||||
|
- [ ] 验收证据记录命令、时间、commit、数据/配置哈希和已知缺口。
|
||||||
|
- [ ] 使用 Lore 协议提交;`Tested` 和 `Not-tested` 如实填写。
|
||||||
|
- [ ] 推送远端并确认提交、CI 和证据可见。
|
||||||
|
- [ ] 只有全部必选项完成后,才把阶段状态改为 `DONE` 并更新整体进度。
|
||||||
|
|
||||||
|
## 15. 下一步执行顺序
|
||||||
|
|
||||||
|
当前只推进 Stage 1,建议按以下顺序开始:
|
||||||
|
|
||||||
|
1. 轮换并受控注入新百炼 Key,确认真实端点不写入仓库。
|
||||||
|
2. 创建最小 Python 包、数据库初始化、Alembic 和 `db + migrate` Compose。
|
||||||
|
3. 用 fake server 实现并验证三模型适配器契约。
|
||||||
|
4. 运行受控 live smoke,记录 provider profile 和能力指纹。
|
||||||
|
5. 用 20 条虚构数据完成写入、检索、重排和幂等闭环。
|
||||||
|
6. 扩展 `make verify`,保存验收证据,完成 S1-A/S1-B/S1-C 提交与推送。
|
||||||
|
|
||||||
|
在 Stage 1 验收之前,不并行展开真实语料入库、完整 UI 或正式验证集调参。
|
||||||
108
docs/05-stage1-runbook.md
Normal file
108
docs/05-stage1-runbook.md
Normal 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`。
|
||||||
@@ -1,11 +1,25 @@
|
|||||||
# 设计文档索引
|
# 设计文档索引
|
||||||
|
|
||||||
|
## 当前进度
|
||||||
|
|
||||||
|
- Stage 0“仓库、安全和设计基线”已完成并推送。
|
||||||
|
- Stage 1“模型与数据库 PoC”正在进行。
|
||||||
|
- 当前整体完成度约 12%,合理区间为 10%–15%。
|
||||||
|
- 00–03 的设计基线已完成;业务代码完成度约 8%,Stage 1 离线数据库/适配器/seed 子闭环已验收,真实百炼 smoke 与前端产品功能尚未完成。
|
||||||
|
- 后续状态、依赖、验收证据、工期和提交节点以 [项目全生命周期 TODO](04-project-todo.md) 为准。
|
||||||
|
|
||||||
|
## 文档列表
|
||||||
|
|
||||||
| 文档 | 内容 | 状态 |
|
| 文档 | 内容 | 状态 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| [00-overall-design.md](00-overall-design.md) | 项目范围、总体架构、技术选型、接口、数据模型与 RAG 主流程 | 设计基线完成 |
|
| [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。
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
161
ops/postgres/init/10-bootstrap-rag.sh
Executable file
161
ops/postgres/init/10-bootstrap-rag.sh
Executable 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'
|
||||||
@@ -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
45
scripts/init-local-secrets.sh
Executable 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
|
||||||
Reference in New Issue
Block a user