Compare commits

..

12 commits

Author SHA1 Message Date
febux
c4b22be773 Architecture diagram was created.
Some checks failed
CI / Lint & typecheck (push) Successful in 1m44s
CI / Unit tests (push) Successful in 1m1s
CI / Integration tests (push) Failing after 23s
2026-09-12 13:00:57 +03:00
febux
07b1994c32 Fix CI integration tests. 2026-09-06 21:23:42 +03:00
febux
5aff6e569e Fix CI integration tests. 2026-09-06 20:51:16 +03:00
febux
206e55d925 Update ci.yml 2026-09-06 20:42:43 +03:00
febux
1c08e7362c Fix stream not read error for files whose recieved from bot. 2026-09-06 20:41:35 +03:00
febux
85db70887d Fix Vector config and OpenObserve auth for live E2E verification
Ticket 10 verification surfaced runtime issues that static validation
missed; all verified against running Docker stack (Vector 0.43 + OO 0.92.2):

- Rewrite VRL for Vector 0.43: no coalesce/default funcs, merge!/replace!
  for guarded fallible calls, no two-value parse_json destructuring
- Quote env-interpolated sink credentials (empty env left a bare YAML null)
- Fix logs sink URI: OO needs /api/{org}/{stream}/_json (stream segment
  was missing, causing 404); logs now land in contract_check stream
- Replace OPENOBSERVE_AUTH_TOKEN with root email/password Basic auth:
  prometheus_remote_write ignores request.headers, so remote-write got 401
- Update .env.example, DEPLOY.md, ARCHITECTURE.md, ticket 05 accordingly

Verified live: logs with service + correlation_id searchable in
OpenObserve; contract_check_* metrics queryable via its Prometheus API;
no outbound port 4318 connections from app containers.
2026-09-06 19:41:04 +03:00
febux
b279d6c61a Switch observability to passive collection: remove OTLP push, Vector replaces otel-collector
- core/telemetry.py is now a no-op (no opentelemetry imports); entrypoints
  no longer call setup/shutdown telemetry
- Remove all opentelemetry-* deps from pyproject groups; regenerate uv.lock
- Drop otel_exporter_otlp_endpoint/otel_service_name settings; sentry and
  auth use "contract-check" instead
- Stop publishing worker metrics ports; bind API metrics to 127.0.0.1 by
  default via API_METRICS_BIND_HOST
- Replace otel-collector with Vector in observer profile (docker_logs +
  prometheus_scrape -> OpenObserve); add deploy/observability/vector-config.yaml
- Update .env.example, docs (ARCHITECTURE, DEPLOY), README, Makefile
- Rewrite tests/unit/test_telemetry.py for the no-op implementation
2026-09-06 19:17:08 +03:00
febux
dc653737e0 Redis maint notif was disabled. ONLY FOR REDIS CLOUD 2026-09-06 17:56:36 +03:00
febux
ec2bcc9402 Bot reports list statuses were fixed. 2026-09-06 17:52:31 +03:00
febux
3cfb238adb Merge branch 'python-3.14-trixie' into master 2026-09-06 17:42:41 +03:00
febux
654fe64453 Integration tests were fixed. 2026-09-06 17:37:58 +03:00
febux
f24117c2d1 build: upgrade to Python 3.14 on Debian Trixie
- Bump requires-python to >=3.14 and ruff target-version to py314
- Switch all service images to uv:python3.14-trixie-slim builder
  and python:3.14-slim-trixie runtime
- Rename libmagic1 -> libmagic1t64 in worker-extract for Trixie
- Regenerate uv.lock under Python 3.14
- Update ARCHITECTURE.md to reflect py3.14 + Trixie
2026-09-06 15:29:49 +03:00
53 changed files with 16886 additions and 1668 deletions

View file

@ -48,22 +48,16 @@ REFUND_FULL_USAGE_THRESHOLD=0.20 # ≤20% consumed + within window ⇒ full ref
# --- Observability (leave empty to disable) --- # --- Observability (leave empty to disable) ---
SENTRY_DSN= SENTRY_DSN=
# OTLP endpoint for traces/metrics/logs. # OTEL_EXPORTER_OTLP_ENDPOINT is no longer used. The application does not push
# profile: observer → http://otel-collector:4318 (OTLP/HTTP via collector) # OTLP; Vector/OpenObserve collects logs/metrics passively from stdout and
# profile: obs → configure your own otel-collector/tempo, or leave empty # /metrics endpoints. Remove this line from existing .env files.
OTEL_EXPORTER_OTLP_ENDPOINT= # OTEL_EXPORTER_OTLP_ENDPOINT=
# Auth header for OpenObserve. Used by the otel-collector to forward telemetry. # OTEL_EXPORTER_OTLP_HEADERS=
# Generate with: echo -n 'user:pass' | base64
# Default value is for root@example.com:Complexpass#123.
OPENOBSERVE_AUTH_TOKEN=cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM=
# Only needed if sending OTLP directly to OpenObserve (without collector).
# The collector uses OPENOBSERVE_AUTH_TOKEN instead.
OTEL_EXPORTER_OTLP_HEADERS=
OTEL_SERVICE_NAME=contract-check
# --- OpenObserve (profile: observer) --- # --- OpenObserve (profile: observer) ---
# Vector (profile `observer`) authenticates to OpenObserve with these
# credentials, so keep them in sync with the OpenObserve root user below.
OPENOBSERVE_PORT=5080 OPENOBSERVE_PORT=5080
OPENOBSERVE_GRPC_PORT=5081
OPENOBSERVE_ROOT_USER_EMAIL=root@example.com OPENOBSERVE_ROOT_USER_EMAIL=root@example.com
OPENOBSERVE_ROOT_USER_PASSWORD=Complexpass#123 OPENOBSERVE_ROOT_USER_PASSWORD=Complexpass#123
# disk | s3 (s3 requires ZO_S3_* env vars; see OpenObserve docs) # disk | s3 (s3 requires ZO_S3_* env vars; see OpenObserve docs)
@ -77,11 +71,6 @@ OPENOBSERVE_MEMORY_LIMIT=2G
OPENOBSERVE_CPU_RESERVATION=0.5 OPENOBSERVE_CPU_RESERVATION=0.5
OPENOBSERVE_MEMORY_RESERVATION=512M OPENOBSERVE_MEMORY_RESERVATION=512M
# --- OTel Collector (profile: observer) ---
OTEL_COLLECTOR_OTLP_GRPC_PORT=4317
OTEL_COLLECTOR_OTLP_HTTP_PORT=4318
OTEL_COLLECTOR_METRICS_PORT=8889
# --- Grafana / Loki (profile: obs) --- # --- Grafana / Loki (profile: obs) ---
GRAFANA_PORT=3000 GRAFANA_PORT=3000
GRAFANA_ADMIN_USER=admin GRAFANA_ADMIN_USER=admin
@ -120,9 +109,16 @@ CHUNK_SIZE_CHARS=10000
# --- API (FastAPI) --- # --- API (FastAPI) ---
API_HOST=0.0.0.0 API_HOST=0.0.0.0
API_PORT=8000 API_PORT=8000
# Bind the /metrics endpoint to a host-local address by default so it is not
# exposed on the public interface. Set to 0.0.0.0 only when an external scraper
# (e.g. a separate monitoring host) legitimately needs access.
API_METRICS_BIND_HOST=127.0.0.1
API_METRICS_PORT=9100 API_METRICS_PORT=9100
B2B_DEFAULT_RATE_LIMIT_RPS=3 # per API key; mirrors Ollama Pro concurrency B2B_DEFAULT_RATE_LIMIT_RPS=3 # per API key; mirrors Ollama Pro concurrency
# Bearer token protecting /metrics. Leave empty to keep the endpoint open (default). # Bearer token protecting /metrics. Generate a strong secret for production and
# share it with authorized scrapers. Leaving this empty keeps /metrics open, which
# is convenient for local development but should not be used in production.
# Generate with: openssl rand -hex 32
METRICS_BEARER_TOKEN= METRICS_BEARER_TOKEN=
CORS_ORIGINS= # comma-separated, future web SPA CORS_ORIGINS= # comma-separated, future web SPA

View file

@ -60,33 +60,37 @@ jobs:
run: uv run pytest --cov=src/contract_check --cov-branch --cov-report=term-missing --cov-fail-under=50 -m "not integration" tests/unit run: uv run pytest --cov=src/contract_check --cov-branch --cov-report=term-missing --cov-fail-under=50 -m "not integration" tests/unit
# Integration tests. # Integration tests.
# test-integration: test-integration:
# name: Integration tests name: Integration tests
# runs-on: ubuntu-latest runs-on: ubuntu-latest
# steps: steps:
# - uses: actions/checkout@v4 - uses: actions/checkout@v4
# - name: Setup uv - name: Setup uv
# uses: astral-sh/setup-uv@v8.3.2 uses: astral-sh/setup-uv@v8.3.2
# with: with:
# enable-cache: true enable-cache: true
# cache-dependency-glob: uv.lock cache-dependency-glob: uv.lock
# - name: Install Python - name: Install Python
# run: uv python install run: uv python install
# - name: Sync dev dependencies - name: Sync dev dependencies
# run: uv sync --group dev --frozen run: uv sync --group dev --frozen
# - name: Start infrastructure (postgres/redis/rabbitmq/minio) # No --wait: minio-init-test is a one-shot container that exits (0) after
# run: docker compose up -d --wait # creating the bucket, and --wait treats any exited container as failure.
# Health polling is done by the `infra` fixture in
# tests/integration/conftest.py; this step just pre-pulls the images.
- name: Start test infrastructure (postgres/redis/rabbitmq/minio)
run: docker compose -p contract-check-test -f docker-compose.test.yml up -d
# - name: Run integration tests - name: Run integration tests
# run: uv run pytest -m integration run: uv run pytest -m integration
# - name: Teardown infrastructure - name: Teardown infrastructure
# if: always() if: always()
# run: docker compose down -v run: docker compose -p contract-check-test -f docker-compose.test.yml down -v
# Build and push service images on pushes to main. Set these repository secrets: # Build and push service images on pushes to main. Set these repository secrets:
# REGISTRY e.g. ghcr.io (or docker.io, your-private-registry.io) # REGISTRY e.g. ghcr.io (or docker.io, your-private-registry.io)

View file

@ -15,6 +15,7 @@ repos:
exclude: ^deploy/vps/docker-compose\.override\.example\.yml$ exclude: ^deploy/vps/docker-compose\.override\.example\.yml$
- id: check-toml - id: check-toml
- id: check-added-large-files - id: check-added-large-files
exclude: ^docs/
- id: check-merge-conflict - id: check-merge-conflict
- id: mixed-line-ending - id: mixed-line-ending
args: [--fix=lf] args: [--fix=lf]

View file

@ -1 +1 @@
3.13 3.14

View file

@ -132,13 +132,13 @@ obs-url: ## Print Grafana URL and default credentials
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Docker: OpenObserve collector stack # Docker: OpenObserve collector stack
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
observer-up: ## Start OpenObserve + otel-collector containers observer-up: ## Start OpenObserve + Vector containers
docker compose --profile observer up -d --build --remove-orphans docker compose --profile observer up -d --build --remove-orphans
observer-down: ## Stop OpenObserve + otel-collector containers observer-down: ## Stop OpenObserve + Vector containers
docker compose --profile observer down docker compose --profile observer down
observer-logs: ## Tail OpenObserve + otel-collector containers logs observer-logs: ## Tail OpenObserve + Vector containers logs
docker compose --profile observer logs -f docker compose --profile observer logs -f
obs-reset: ## Reset Grafana and Loki volumes (wipes dashboards/logs data) obs-reset: ## Reset Grafana and Loki volumes (wipes dashboards/logs data)

View file

@ -143,11 +143,12 @@ docker compose --profile bot up -d --build # + bot (можно запус
- `services` — api + worker-ы (без бота). - `services` — api + worker-ы (без бота).
- `bot` — Telegram-бот; можно поднять на этом же хосте или на отдельном сервере (`docs/DEPLOY.md` §14). - `bot` — Telegram-бот; можно поднять на этом же хосте или на отдельном сервере (`docs/DEPLOY.md` §14).
- `edge` — Nginx + certbot (`deploy/nginx/`, `docs/DEPLOY.md` §13). - `edge` — Nginx + certbot (`deploy/nginx/`, `docs/DEPLOY.md` §13).
- `obs` / `observer` — observability (Grafana/Loki/Prometheus или OpenObserve + OTel collector). - `obs` / `observer` — observability (Grafana/Loki/Prometheus или OpenObserve + Vector).
Порты на хосте (смещены, чтобы не конфликтовать): Postgres `15432`, Redis `17379`, Порты на хосте (смещены, чтобы не конфликтовать): Postgres `15432`, Redis `17379`,
RabbitMQ AMQP `5672` / UI `15672`, MinIO `9000` / console `9001`, api `8000` / metrics `9100`, RabbitMQ AMQP `5672` / UI `15672`, MinIO `9000` / console `9001`, api `8000` / metrics `9100`
worker metrics: extract `9101`, analyze `9102`, notify `9103`, prescreen `9104`, billing `9105`, edge `80`/`443`. (по умолчанию только на loopback), OpenObserve UI `5080`, edge `80`/`443`.
Метрики worker-ов больше не публикуются на хост; их скрейпит Vector внутри сети compose.
## API (кратко) ## API (кратко)

View file

@ -1,70 +0,0 @@
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
# Scrape the Prometheus /metrics endpoints exposed by api and workers.
prometheus:
config:
scrape_configs:
- job_name: contract-check
static_configs:
- targets:
- api:9100
- worker-extract:9101
- worker-analyze:9102
- worker-prescreen:9104
- worker-billing:9105
- worker-notify:9103
processors:
batch:
timeout: 1s
send_batch_size: 1024
exporters:
# Forward logs and traces to the OpenObserve OTLP/HTTP endpoint.
# The /openobserve prefix matches ZO_BASE_URI in docker-compose.yml.
otlphttp/openobserve:
endpoint: http://openobserve:5080/openobserve/api/default
headers:
Authorization: Basic ${env:OPENOBSERVE_AUTH_TOKEN}
tls:
insecure: true
# OpenObserve metrics are ingested via Prometheus remote-write.
prometheusremotewrite/openobserve:
endpoint: http://openobserve:5080/openobserve/api/default/prometheus/api/v1/write
headers:
Authorization: Basic ${env:OPENOBSERVE_AUTH_TOKEN}
tls:
insecure: true
# Echo pipeline data to the collector's own logs (useful for debugging).
debug:
verbosity: detailed
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlphttp/openobserve]
metrics:
receivers: [otlp, prometheus]
processors: [batch]
exporters: [prometheusremotewrite/openobserve]
logs:
receivers: [otlp]
processors: [batch]
exporters: [otlphttp/openobserve]
# Collector's own telemetry (optional, useful for debugging).
telemetry:
logs:
level: info
metrics:
level: detailed

View file

@ -0,0 +1,109 @@
# Vector configuration for the `observer` profile (passive observability).
#
# Vector reads container logs from the local Docker socket and scrapes service
# /metrics endpoints inside the compose network, then forwards logs and metrics
# to OpenObserve. The application itself does not push telemetry.
# -----------------------------------------------------------------------------
# Sources
# -----------------------------------------------------------------------------
sources:
docker_logs:
type: docker_logs
# Read from the local Docker socket mounted by compose.
docker_host: unix:///var/run/docker.sock
include_labels:
- com.docker.compose.project=dealdocumentscreening
service_metrics:
type: prometheus_scrape
endpoints:
- http://api:8000/metrics
- http://worker-extract:9101/metrics
- http://worker-analyze:9102/metrics
- http://worker-prescreen:9104/metrics
- http://worker-billing:9105/metrics
- http://worker-notify:9103/metrics
scrape_interval_secs: 15
# The token is optional: an empty METRICS_BEARER_TOKEN leaves /metrics open.
authorization:
strategy: bearer
token: "${METRICS_BEARER_TOKEN-}"
# -----------------------------------------------------------------------------
# Transforms
# -----------------------------------------------------------------------------
transforms:
enrich_logs:
type: remap
inputs:
- docker_logs
source: |
# Derive a short service label from the container metadata
# (docker_logs always provides container_name).
.container = .container_name
.service = .container_name
# If the log line is JSON from our structured logger, merge its fields
# without overwriting Vector/container metadata.
raw = .message
if is_string(raw) {
parsed = parse_json(raw) ?? null
if is_object(parsed) {
. = merge!(parsed, .)
service = parsed.service
if service != null {
.service = service
}
correlation_id = parsed.correlation_id
if correlation_id != null {
.correlation_id = correlation_id
}
}
}
# Trim the compose project prefix for a cleaner service label.
if is_string(.service) {
.service = replace!(.service, r'^contract_check-', "")
}
# -----------------------------------------------------------------------------
# Sinks
# -----------------------------------------------------------------------------
sinks:
openobserve_logs:
type: http
inputs:
- enrich_logs
uri: http://openobserve:5080/openobserve/api/default/contract_check/_json
method: post
auth:
strategy: basic
user: "${OPENOBSERVE_ROOT_USER_EMAIL-}"
password: "${OPENOBSERVE_ROOT_USER_PASSWORD-}"
encoding:
codec: json
batch:
max_events: 100
timeout_secs: 1
request:
headers:
Content-Type: application/json
openobserve_metrics:
type: prometheus_remote_write
inputs:
- service_metrics
# OpenObserve answers Vector's healthcheck probe with 405; this is
# harmless — actual remote-write POSTs succeed (200).
endpoint: http://openobserve:5080/openobserve/api/default/prometheus/api/v1/write
auth:
strategy: basic
user: "${OPENOBSERVE_ROOT_USER_EMAIL-}"
password: "${OPENOBSERVE_ROOT_USER_PASSWORD-}"
batch:
max_events: 100
timeout_secs: 1

127
docker-compose.test.yml Normal file
View file

@ -0,0 +1,127 @@
# Test-only infrastructure for integration tests.
#
# Mirrors the real infra (postgres + redis + rabbitmq + minio) but uses
# dedicated container names and host ports so it can coexist with the
# development stack (`docker-compose.yml`). Nothing in this file runs
# application services — only the data stores required by
# tests/integration/conftest.py.
#
# Usage:
# docker compose -p contract-check-test -f docker-compose.test.yml up -d
# uv run pytest -m integration
# docker compose -p contract-check-test -f docker-compose.test.yml down -v
#
# Do NOT use --wait: minio-init-test is a one-shot container that exits after
# creating the bucket, and --wait treats any exited container as a failure
# (even with exit code 0). Health polling is handled by conftest.py.
#
# The conftest.py fixture `infra` brings this file up automatically before the
# first integration test and tears it down after the session.
services:
postgres-test:
image: postgres:18-alpine
restart: "no"
command:
- "postgres"
- "-c"
- "wal_level=replica"
- "-c"
- "archive_mode=on"
- "-c"
- "archive_command=test ! -f /walarchive/%f && cp %p /walarchive/%f"
environment:
POSTGRES_USER: contract_check
POSTGRES_PASSWORD: contract_check
POSTGRES_DB: contract_check
volumes:
- pgdata-test:/var/lib/postgresql
- pgwal-test:/walarchive
ports:
- "25432:5432"
healthcheck:
test:
- CMD-SHELL
- "pg_isready -U contract_check -d contract_check"
interval: 5s
timeout: 3s
retries: 10
redis-test:
image: redis:8-alpine
restart: "no"
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redisdata-test:/data
ports:
- "27379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
rabbitmq-test:
image: rabbitmq:4-management-alpine
restart: "no"
environment:
RABBITMQ_DEFAULT_USER: contract_check
RABBITMQ_DEFAULT_PASS: contract_check
RABBITMQ_DEFAULT_VHOST: /
volumes:
- rabbitmq-test:/var/lib/rabbitmq
ports:
- "6672:5672" # AMQP
- "25672:15672" # management UI (http://localhost:25672)
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 10s
timeout: 5s
retries: 10
start_period: 15s
minio-test:
image: minio/minio:latest
restart: "no"
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: contract_check
MINIO_ROOT_PASSWORD: contract_check
volumes:
- minio-test:/data
ports:
- "10000:9000" # S3 API
- "10001:9001" # console (http://localhost:10001)
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:9000/minio/health/ready"]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
minio-init-test:
image: minio/mc:latest
depends_on:
minio-test:
condition: service_healthy
entrypoint: /bin/sh
command:
- -c
- |
set -e
mc alias set local http://minio-test:9000 contract_check contract_check
mc mb --ignore-existing local/contract-check-docs-test
mc anonymous set none local/contract-check-docs-test || true
mc ilm rule add --expire-days 7 local/contract-check-docs-test || true
echo "bucket contract-check-docs-test ready"
environment:
MINIO_ROOT_USER: contract_check
MINIO_ROOT_PASSWORD: contract_check
restart: "no"
volumes:
pgdata-test:
pgwal-test:
redisdata-test:
rabbitmq-test:
minio-test:

View file

@ -150,10 +150,9 @@ services:
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/} RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/}
S3_ENDPOINT_URL: http://minio:9000 S3_ENDPOINT_URL: http://minio:9000
OTEL_SERVICE_NAME: api
ports: ports:
- "${API_PORT:-8000}:8000" - "${API_PORT:-8000}:8000"
- "${API_METRICS_PORT:-9100}:9100" - "${API_METRICS_BIND_HOST:-127.0.0.1}:${API_METRICS_PORT:-9100}:9100"
healthcheck: healthcheck:
test: test:
- CMD-SHELL - CMD-SHELL
@ -188,9 +187,6 @@ services:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-contract_check}:${POSTGRES_PASSWORD:-contract_check}@postgres:5432/${POSTGRES_DB:-contract_check} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-contract_check}:${POSTGRES_PASSWORD:-contract_check}@postgres:5432/${POSTGRES_DB:-contract_check}
RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/} RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/}
S3_ENDPOINT_URL: http://minio:9000 S3_ENDPOINT_URL: http://minio:9000
OTEL_SERVICE_NAME: worker-extract
ports:
- "9101:9101"
worker-analyze: worker-analyze:
profiles: ["services"] profiles: ["services"]
@ -217,9 +213,6 @@ services:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-contract_check}:${POSTGRES_PASSWORD:-contract_check}@postgres:5432/${POSTGRES_DB:-contract_check} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-contract_check}:${POSTGRES_PASSWORD:-contract_check}@postgres:5432/${POSTGRES_DB:-contract_check}
RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/} RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/}
S3_ENDPOINT_URL: http://minio:9000 S3_ENDPOINT_URL: http://minio:9000
OTEL_SERVICE_NAME: worker-analyze
ports:
- "9102:9102"
worker-prescreen: worker-prescreen:
profiles: ["services"] profiles: ["services"]
@ -246,9 +239,6 @@ services:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-contract_check}:${POSTGRES_PASSWORD:-contract_check}@postgres:5432/${POSTGRES_DB:-contract_check} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-contract_check}:${POSTGRES_PASSWORD:-contract_check}@postgres:5432/${POSTGRES_DB:-contract_check}
RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/} RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/}
S3_ENDPOINT_URL: http://minio:9000 S3_ENDPOINT_URL: http://minio:9000
OTEL_SERVICE_NAME: worker-prescreen
ports:
- "9104:9104"
worker-billing: worker-billing:
profiles: ["services"] profiles: ["services"]
@ -267,9 +257,6 @@ services:
required: false required: false
environment: environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-contract_check}:${POSTGRES_PASSWORD:-contract_check}@postgres:5432/${POSTGRES_DB:-contract_check} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-contract_check}:${POSTGRES_PASSWORD:-contract_check}@postgres:5432/${POSTGRES_DB:-contract_check}
OTEL_SERVICE_NAME: worker-billing
ports:
- "9105:9105"
worker-notify: worker-notify:
profiles: ["services"] profiles: ["services"]
@ -292,9 +279,6 @@ services:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-contract_check}:${POSTGRES_PASSWORD:-contract_check}@postgres:5432/${POSTGRES_DB:-contract_check} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-contract_check}:${POSTGRES_PASSWORD:-contract_check}@postgres:5432/${POSTGRES_DB:-contract_check}
RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/} RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/}
S3_ENDPOINT_URL: http://minio:9000 S3_ENDPOINT_URL: http://minio:9000
OTEL_SERVICE_NAME: worker-notify
ports:
- "9103:9103"
# Telegram bot adapter (aiogram 3, HTTP-only to api). Per docs/ARCHITECTURE.md §17 # Telegram bot adapter (aiogram 3, HTTP-only to api). Per docs/ARCHITECTURE.md §17
# the bot holds no DB/MQ/S3 credentials — it talks only to the API. # the bot holds no DB/MQ/S3 credentials — it talks only to the API.
@ -399,16 +383,16 @@ services:
# ── OBSERVABILITY ────────────────────────────────────────────────────────── # ── OBSERVABILITY ──────────────────────────────────────────────────────────
# Two observability stacks are provided; activate exactly one profile. # Two observability stacks are provided; activate exactly one profile.
# #
# profile: observer → OpenObserve (lightweight, single binary). # profile: observer → OpenObserve via Vector (lightweight, single binary).
# Use for dev/homelab or when hardware is tight. # Vector scrapes container logs from the Docker socket
# and scrapes service /metrics endpoints; application
# code does not push telemetry.
# profile: obs → Grafana + Prometheus + Loki (mature, heavier). # profile: obs → Grafana + Prometheus + Loki (mature, heavier).
# Use for production-grade visibility. # Use for production-grade visibility.
# #
# App services send OTLP traces/metrics/logs to the endpoint configured in # Vector (profile `observer`) authenticates to OpenObserve with
# OTEL_EXPORTER_OTLP_ENDPOINT (.env). Point it at otel-collector:4318 for the # OPENOBSERVE_ROOT_USER_EMAIL / OPENOBSERVE_ROOT_USER_PASSWORD. No OTLP
# lightweight profile (the app uses OTLP/HTTP exporters). The collector then # endpoint configuration is needed in the app.
# forwards logs/metrics/traces to OpenObserve. For the obs profile you can
# point this at your own otel-collector/tempo or leave it empty.
# ── LIGHTWEIGHT OBSERVABILITY (profile: observer) ─────────────────────────── # ── LIGHTWEIGHT OBSERVABILITY (profile: observer) ───────────────────────────
openobserve: openobserve:
@ -429,8 +413,7 @@ services:
volumes: volumes:
- openobserve-data:/data - openobserve-data:/data
ports: ports:
- "${OPENOBSERVE_PORT:-5080}:5080" # UI + OTLP/HTTP - "${OPENOBSERVE_PORT:-5080}:5080" # UI + ingestion endpoints
- "${OPENOBSERVE_GRPC_PORT:-5081}:5081" # OTLP/gRPC
deploy: deploy:
resources: resources:
limits: limits:
@ -440,20 +423,18 @@ services:
cpus: "${OPENOBSERVE_CPU_RESERVATION:-0.5}" cpus: "${OPENOBSERVE_CPU_RESERVATION:-0.5}"
memory: "${OPENOBSERVE_MEMORY_RESERVATION:-512M}" memory: "${OPENOBSERVE_MEMORY_RESERVATION:-512M}"
otel-collector: vector:
profiles: ["observer"] profiles: ["observer"]
image: otel/opentelemetry-collector-contrib:latest image: timberio/vector:0.43.0-alpine
container_name: contract_check-otel-collector container_name: contract_check-vector
restart: unless-stopped restart: unless-stopped
command: ["--config", "/etc/otelcol/config.yaml"]
environment: environment:
OPENOBSERVE_AUTH_TOKEN: ${OPENOBSERVE_AUTH_TOKEN} OPENOBSERVE_ROOT_USER_EMAIL: ${OPENOBSERVE_ROOT_USER_EMAIL:-root@example.com}
OPENOBSERVE_ROOT_USER_PASSWORD: ${OPENOBSERVE_ROOT_USER_PASSWORD:-Complexpass#123}
METRICS_BEARER_TOKEN: ${METRICS_BEARER_TOKEN:-}
volumes: volumes:
- ./deploy/observability/otel-collector-config.yaml:/etc/otelcol/config.yaml:ro - /var/run/docker.sock:/var/run/docker.sock:ro
ports: - ./deploy/observability/vector-config.yaml:/etc/vector/vector.yaml:ro
- "${OTEL_COLLECTOR_OTLP_GRPC_PORT:-4317}:4317" # OTLP gRPC receiver
- "${OTEL_COLLECTOR_OTLP_HTTP_PORT:-4318}:4318" # OTLP HTTP receiver
- "${OTEL_COLLECTOR_METRICS_PORT:-8889}:8889" # Collector self-metrics
depends_on: depends_on:
openobserve: openobserve:
condition: service_started condition: service_started

View file

@ -13,7 +13,7 @@
> **workers** (extract/OCR, deterministic prescreen, and LLM-analyze, split for > **workers** (extract/OCR, deterministic prescreen, and LLM-analyze, split for
> CPU vs I/O profiles), a Telegram **bot** adapter, backed by > CPU vs I/O profiles), a Telegram **bot** adapter, backed by
> **Postgres + RabbitMQ + MinIO + Redis**, observable via > **Postgres + RabbitMQ + MinIO + Redis**, observable via
> **structlog + Sentry + Prometheus/Grafana + OpenTelemetry**, behind > **structlog + Sentry + Prometheus/Grafana + OpenObserve/Vector**, behind
> **Nginx + certbot**. Monorepo, six fine-tuned Docker images > **Nginx + certbot**. Monorepo, six fine-tuned Docker images
> (`srv/` — api + three workers + bot + prototype), shared `contract_check.core` > (`srv/` — api + three workers + bot + prototype), shared `contract_check.core`
> domain package. Compose now, k8s-ready later. > domain package. Compose now, k8s-ready later.
@ -51,7 +51,8 @@ Additional decisions locked during the architecture questionnaire:
- **Refund policy is a runtime switch** (`REFUND_POLICY=all|infra_only`) with - **Refund policy is a runtime switch** (`REFUND_POLICY=all|infra_only`) with
a failure-class taxonomy; credit refund stays idempotent. a failure-class taxonomy; credit refund stays idempotent.
- **Observability is full**, not logs-only: structlog+correlation_id, Sentry, - **Observability is full**, not logs-only: structlog+correlation_id, Sentry,
Prometheus+Grafana, OpenTelemetry. Prometheus+Grafana; the lightweight `observer` profile uses Vector+OpenObserve.
OpenTelemetry push has been removed in favour of passive collection.
- **Payments (ЮKassa) live** behind a provider port (`core/billing/port.py`), - **Payments (ЮKassa) live** behind a provider port (`core/billing/port.py`),
webhook-driven state machine, plans/subscriptions + credit top-ups, and a webhook-driven state machine, plans/subscriptions + credit top-ups, and a
`worker-billing` scheduler (renewals/expiry/reconciliation). Disabled by `worker-billing` scheduler (renewals/expiry/reconciliation). Disabled by
@ -129,14 +130,14 @@ Control plane:
| Extra tables | jobs, service_tokens, invoices(stub) | 3-table plan | | Extra tables | jobs, service_tokens, invoices(stub) | 3-table plan |
| Report storage | JSONB + markdown column | — | | Report storage | JSONB + markdown column | — |
| Durability | HA-*ready* (quorum queues, WAL archive) | — | | Durability | HA-*ready* (quorum queues, WAL archive) | — |
| Observability | structlog+corr, Sentry, Prom/Grafana, OTel | docker logs | | Observability | structlog+corr, Sentry, Prom/Grafana, Vector/OpenObserve | docker logs |
| LLM | Provider port + Ollama Cloud adapter | direct client | | LLM | Provider port + Ollama Cloud adapter | direct client |
| Refund | Policy switch `all\|infra_only` + failure classes | refund-all | | Refund | Policy switch `all\|infra_only` + failure classes | refund-all |
| Edge | Nginx + certbot | — | | Edge | Nginx + certbot | — |
| Deploy | Compose now, k8s-ready later | — | | Deploy | Compose now, k8s-ready later | — |
| Prototype | Removed (stage-0 standalone benchmark no longer needed) | Kept as standalone benchmark | | Prototype | Removed (stage-0 standalone benchmark no longer needed) | Kept as standalone benchmark |
| Tests | pytest+respx unit + testcontainers integration | — | | Tests | pytest+respx unit + testcontainers integration | — |
| Python | **3.13** (was 3.14) — wheel availability | py3.14 | | Python | **3.14** | py3.13 |
| Landing | Incremental, green per step | — | | Landing | Incremental, green per step | — |
--- ---
@ -167,7 +168,7 @@ DealDocumentScreening/
│ │ └── certbot-init.sh (initial cert + nginx reload) │ │ └── certbot-init.sh (initial cert + nginx reload)
│ └── observability/ │ └── observability/
│ ├── prometheus/prometheus.yml (scrape api + workers :9100..:9105) │ ├── prometheus/prometheus.yml (scrape api + workers :9100..:9105)
│ ├── otel-collector-config.yaml (OpenObserve profile: receiver + forwarder) │ ├── vector-config.yaml (OpenObserve profile: Vector log/metric collection)
│ ├── loki-config.yaml (Grafana/Loki profile) │ ├── loki-config.yaml (Grafana/Loki profile)
│ ├── tempo.yaml (planned — trace storage for Grafana stack) │ ├── tempo.yaml (planned — trace storage for Grafana stack)
│ └── grafana/provisioning/ │ └── grafana/provisioning/
@ -185,7 +186,7 @@ DealDocumentScreening/
│ │ ├── __init__.py │ │ ├── __init__.py
│ │ ├── config.py (pydantic-settings: base + per-service, §11) │ │ ├── config.py (pydantic-settings: base + per-service, §11)
│ │ ├── logging.py (structlog JSON + correlation_id contextvar) │ │ ├── logging.py (structlog JSON + correlation_id contextvar)
│ │ ├── telemetry.py (OTel SDK init, FastAPI/asyncio instrumentation) │ │ ├── telemetry.py (no-op stubs; OTLP push removed)
│ │ ├── sentry.py (sentry_sdk init helper) │ │ ├── sentry.py (sentry_sdk init helper)
│ │ ├── metrics.py (prometheus_client registry + counters/hists) │ │ ├── metrics.py (prometheus_client registry + counters/hists)
│ │ ├── api_keys.py (B2B key gen/hash/verify — sha256 + hmac.compare_digest) │ │ ├── api_keys.py (B2B key gen/hash/verify — sha256 + hmac.compare_digest)
@ -1028,7 +1029,7 @@ change.
### Volumes & restart ### Volumes & restart
- Named volumes for `pgdata`, `rabbitmq`, `minio`, `redis`, `prometheus`, - Named volumes for `pgdata`, `rabbitmq`, `minio`, `redis`, `prometheus`,
`grafana`, `tempo`. Bind-mount only `./deploy` configs and cert dirs. `grafana`. Bind-mount only `./deploy` configs and cert dirs.
- `restart: unless-stopped` on every long-running service. - `restart: unless-stopped` on every long-running service.
- `depends_on: condition: service_healthy` everywhere with real healthchecks - `depends_on: condition: service_healthy` everywhere with real healthchecks
(pg `pg_isready`, rabbit `rabbitmq-diagnostics ping`, minio `mc ready`, (pg `pg_isready`, rabbit `rabbitmq-diagnostics ping`, minio `mc ready`,
@ -1086,12 +1087,11 @@ None of 15 requires touching `core/` application code — only compose/infra.
| `REFUND_WINDOW_DAYS` | `14` | full-refund window (§8a) | | `REFUND_WINDOW_DAYS` | `14` | full-refund window (§8a) |
| `REFUND_FULL_USAGE_THRESHOLD` | `0.20` | usage ratio for full refund | | `REFUND_FULL_USAGE_THRESHOLD` | `0.20` | usage ratio for full refund |
| `SENTRY_DSN` | (empty) | if set, init sentry | | `SENTRY_DSN` | (empty) | if set, init sentry |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | (empty) | OTLP endpoint; with profile `observer` use `http://otel-collector:4318` | | `OPENOBSERVE_ROOT_USER_EMAIL` | `root@example.com` | OpenObserve root user; Vector uses it for Basic auth |
| `OTEL_SERVICE_NAME` | per-service | overridden in each service settings | | `OPENOBSERVE_ROOT_USER_PASSWORD` | `Complexpass#123` | OpenObserve root password; Vector uses it for Basic auth |
| `OPENOBSERVE_AUTH_TOKEN` | default creds base64 | Basic auth token the collector uses to forward to OpenObserve | | `OTEL_EXPORTER_OTLP_ENDPOINT` | (removed) | no longer used; the application does not push OTLP |
| `OTEL_COLLECTOR_OTLP_GRPC_PORT` | `4317` | host port for collector OTLP/gRPC receiver | | `OTEL_EXPORTER_OTLP_HEADERS` | (removed) | no longer used |
| `OTEL_COLLECTOR_OTLP_HTTP_PORT` | `4318` | host port for collector OTLP/HTTP receiver | | `OTEL_SERVICE_NAME` | (removed) | service name is set by `configure_logging()` and compose labels |
| `OTEL_COLLECTOR_METRICS_PORT` | `8889` | host port for collector self-metrics |
| `GRAFANA_PORT` | `3000` | host port for the Grafana UI (profile `obs`) | | `GRAFANA_PORT` | `3000` | host port for the Grafana UI (profile `obs`) |
| `GRAFANA_ADMIN_USER` | `admin` | initial Grafana admin user | | `GRAFANA_ADMIN_USER` | `admin` | initial Grafana admin user |
| `GRAFANA_ADMIN_PASSWORD` | `admin` | initial Grafana admin password | | `GRAFANA_ADMIN_PASSWORD` | `admin` | initial Grafana admin password |
@ -1141,9 +1141,10 @@ None of 15 requires touching `core/` application code — only compose/infra.
|---|---|---| |---|---|---|
| `API_HOST` | `0.0.0.0` | | | `API_HOST` | `0.0.0.0` | |
| `API_PORT` | `8000` | | | `API_PORT` | `8000` | |
| `API_METRICS_PORT` | `9100` | | | `API_METRICS_BIND_HOST` | `127.0.0.1` | host interface the API /metrics port is bound to (compose only) |
| `API_METRICS_PORT` | `9100` | host port the API /metrics endpoint is published on (compose only; default loopback) |
| `B2B_DEFAULT_RATE_LIMIT_RPS` | `3` | per API key; mirrors Ollama Pro concurrency, overridable per `api_keys.rate_limit_rps` | | `B2B_DEFAULT_RATE_LIMIT_RPS` | `3` | per API key; mirrors Ollama Pro concurrency, overridable per `api_keys.rate_limit_rps` |
| `METRICS_BEARER_TOKEN` | (empty) | Bearer token protecting `/metrics`; empty leaves the endpoint open | | `METRICS_BEARER_TOKEN` | (empty) | Bearer token protecting `/metrics`; empty leaves the endpoint open, so set a secret in production |
| `CORS_ORIGINS` | (empty, future web) | | | `CORS_ORIGINS` | (empty, future web) | |
### Auth (JWT + Telegram identity verification + webUI + admin panel) ### Auth (JWT + Telegram identity verification + webUI + admin panel)
@ -1184,14 +1185,14 @@ Same Ollama env as above; no DB/MQ/S3 env needed.
## 12. Docker — images & compose ## 12. Docker — images & compose
### Per-service Dockerfile pattern (uv, multi-stage, py3.13) ### Per-service Dockerfile pattern (uv, multi-stage, py3.14)
Dockerfiles live in `srv/<service>/Dockerfile` (one per service). Common shape Dockerfiles live in `srv/<service>/Dockerfile` (one per service). Common shape
(shown for api): (shown for api):
```dockerfile ```dockerfile
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never \ ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never \
UV_PROJECT_ENVIRONMENT=/app/.venv UV_PROJECT_ENVIRONMENT=/app/.venv
WORKDIR /app WORKDIR /app
@ -1201,7 +1202,7 @@ COPY src ./src
RUN --mount=type=cache,target=/root/.cache/uv \ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-default-groups --group api uv sync --frozen --no-default-groups --group api
FROM python:3.13-slim AS runtime FROM python:3.14-slim-trixie AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PATH=/app/.venv/bin:$PATH ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PATH=/app/.venv/bin:$PATH
WORKDIR /app WORKDIR /app
COPY --from=builder /app/.venv /app/.venv COPY --from=builder /app/.venv /app/.venv
@ -1218,14 +1219,14 @@ Per-service differences (`uv` uses PEP 735 dependency-groups — see `pyproject.
| Dockerfile | Extra installed | Runtime apt | CMD | Expose | | Dockerfile | Extra installed | Runtime apt | CMD | Expose |
|---|---|---|---|---| |---|---|---|---|---|
| `srv/api/Dockerfile` | `--group api` | none | `python -m contract_check.api` | 8000, 9100 | | `srv/api/Dockerfile` | `--group api` | none | `python -m contract_check.api` | 8000, 9100 |
| `srv/worker-extract/Dockerfile` | `--group extract` | tesseract-ocr, -rus, -eng, libmagic1 | `python -m contract_check.worker_extract` | 9101 | | `srv/worker-extract/Dockerfile` | `--group extract` | tesseract-ocr, -rus, -eng, libmagic1t64 | `python -m contract_check.worker_extract` | 9101 |
| `srv/worker-prescreen/Dockerfile` | `--group prescreen` | none | `python -m contract_check.worker_prescreen` | 9104 | | `srv/worker-prescreen/Dockerfile` | `--group prescreen` | none | `python -m contract_check.worker_prescreen` | 9104 |
| `srv/worker-analyze/Dockerfile` | `--group analyze` | none | `python -m contract_check.worker_analyze` | 9102 | | `srv/worker-analyze/Dockerfile` | `--group analyze` | none | `python -m contract_check.worker_analyze` | 9102 |
| `srv/bot/Dockerfile` | `--group bot` | none | `python -m contract_check.bot` | — | | `srv/bot/Dockerfile` | `--group bot` | none | `python -m contract_check.bot` | — |
The bot image is the leanest (no DB driver, no S3 client, no pymupdf). The The bot image is the leanest (no DB driver, no S3 client, no pymupdf). The
analyze image has httpx but no tesseract/pymupdf. The extract image is the analyze image has httpx but no tesseract/pymupdf. The extract image is the
heaviest (tesseract + language packs + libmagic1). This is the "fine-tuned deps heaviest (tesseract + language packs + libmagic1t64). This is the "fine-tuned deps
per service" payoff. per service" payoff.
### pyproject.toml dependency-groups (actual — PEP 735) ### pyproject.toml dependency-groups (actual — PEP 735)
@ -1239,7 +1240,7 @@ that need them. Sketch (see `pyproject.toml` for the authoritative list):
```toml ```toml
[project] [project]
name = "contract-check" name = "contract-check"
requires-python = ">=3.13" requires-python = ">=3.14"
dependencies = [ dependencies = [
"pydantic>=2.7", "pydantic-settings>=2.3", "structlog>=24.1", "pydantic>=2.7", "pydantic-settings>=2.3", "structlog>=24.1",
"python-dotenv>=1.0", "httpx[http2]>=0.27", "python-dotenv>=1.0", "httpx[http2]>=0.27",
@ -1249,23 +1250,19 @@ dependencies = [
db = ["sqlalchemy>=2.0", "asyncpg>=0.29", "alembic>=1.13"] db = ["sqlalchemy>=2.0", "asyncpg>=0.29", "alembic>=1.13"]
mq = ["aio-pika>=9.4"] mq = ["aio-pika>=9.4"]
s3 = ["minio>=7.2"] s3 = ["minio>=7.2"]
obs = ["prometheus-client>=0.20", "sentry-sdk>=2", obs = ["prometheus-client>=0.20", "sentry-sdk>=2"]
"opentelemetry-sdk>=1.24", "opentelemetry-exporter-otlp>=1.24"]
api = [{ include-group = "db" }, { include-group = "mq" }, api = [{ include-group = "db" }, { include-group = "mq" },
{ include-group = "s3" }, { include-group = "obs" }, { include-group = "s3" }, { include-group = "obs" },
"fastapi>=0.110", "uvicorn[standard]>=0.29", "python-multipart>=0.0.9", "fastapi>=0.110", "uvicorn[standard]>=0.29", "python-multipart>=0.0.9",
"redis>=5.0", "redis>=5.0"]
"opentelemetry-instrumentation-fastapi>=0.45b0",
"opentelemetry-instrumentation-asgi>=0.45b0"]
extract = [{ include-group = "db" }, { include-group = "mq" }, extract = [{ include-group = "db" }, { include-group = "mq" },
{ include-group = "s3" }, { include-group = "obs" }, { include-group = "s3" }, { include-group = "obs" },
"pymupdf>=1.24", "pytesseract>=0.3.10", "pillow>=10", "pymupdf>=1.24", "pytesseract>=0.3.10", "pillow>=10",
"mammoth>=1.8", "striprtf>=0.0.26", "chardet>=5.2", "mammoth>=1.8", "striprtf>=0.0.26", "chardet>=5.2",
"python-magic>=0.4.27"] "python-magic>=0.4.27"]
analyze = [{ include-group = "db" }, { include-group = "mq" }, analyze = [{ include-group = "db" }, { include-group = "mq" },
{ include-group = "s3" }, { include-group = "obs" }, { include-group = "s3" }, { include-group = "obs" }]
"opentelemetry-instrumentation-httpx>=0.45b0"]
prescreen = [{ include-group = "db" }, { include-group = "mq" }, prescreen = [{ include-group = "db" }, { include-group = "mq" },
{ include-group = "s3" }, { include-group = "obs" }] { include-group = "s3" }, { include-group = "obs" }]
bot = ["aiogram>=3.4"] bot = ["aiogram>=3.4"]
@ -1283,8 +1280,8 @@ One file, profiles. Default (`docker compose up`) = infra only.
- `services` profile = api + workers (no bot). - `services` profile = api + workers (no bot).
- `bot` profile = Telegram bot adapter; can run on the same host or a separate - `bot` profile = Telegram bot adapter; can run on the same host or a separate
server (`docs/DEPLOY.md` §14). server (`docs/DEPLOY.md` §14).
- `obs` (Grafana + Loki + Prometheus) and `observer` (OpenObserve + OTel - `obs` (Grafana + Loki + Prometheus) and `observer` (OpenObserve + Vector)
Collector) are mutually exclusive observability stacks. are mutually exclusive observability stacks.
- `edge` = Nginx + certbot. - `edge` = Nginx + certbot.
``` ```
@ -1316,9 +1313,9 @@ services:
prometheus: prom/prometheus; scrapes service /metrics endpoints prometheus: prom/prometheus; scrapes service /metrics endpoints
# ── OBSERVABILITY — OPENOBSERVE STACK (profile: observer) ── # ── OBSERVABILITY — OPENOBSERVE STACK (profile: observer) ──
openobserve: public.ecr.aws/zinclabs/openobserve:latest; UI + OTLP backend on :5080, gRPC on :5081 openobserve: public.ecr.aws/zinclabs/openobserve:latest; UI + JSON/Prometheus-write backend on :5080
otel-collector: otel/opentelemetry-collector-contrib:latest; OTLP receiver 4317/4318, vector: timberio/vector:0.43.0-alpine; reads Docker logs + scrapes /metrics,
scrapes /metrics from services, forwards logs/metrics/traces to OpenObserve forwards logs/metrics to OpenObserve
# ── EDGE (profile: edge) ── # ── EDGE (profile: edge) ──
nginx: nginx:alpine; reverse proxy → api/openobserve/grafana; TLS via certbot nginx: nginx:alpine; reverse proxy → api/openobserve/grafana; TLS via certbot
@ -1344,7 +1341,8 @@ enforcing the adapter boundary even in dependency ordering.
- **workers** read `headers["x-correlation-id"]` on consume and set the same - **workers** read `headers["x-correlation-id"]` on consume and set the same
contextvar before handling. So a single upload's logs trace contextvar before handling. So a single upload's logs trace
api → rabbit → worker-extract → worker-analyze → DB under one ID. api → rabbit → worker-extract → worker-analyze → DB under one ID.
- OTel baggage/span context propagates the same ID for distributed traces. - Distributed tracing is intentionally out of scope; `correlation_id` in
structlog already provides request-level correlation across services.
- The same `correlation_id` is parsed by the Loki datasource as a derived field, - The same `correlation_id` is parsed by the Loki datasource as a derived field,
so you can click through from any log line to every other log line with the so you can click through from any log line to every other log line with the
same id. same id.
@ -1391,35 +1389,36 @@ enforcing the adapter boundary even in dependency ordering.
- `prometheus` in the `obs` profile scrapes `:9100`/`:9101`/`:9102`/`:9104`/`:9105`/`:9103` - `prometheus` in the `obs` profile scrapes `:9100`/`:9101`/`:9102`/`:9104`/`:9105`/`:9103`
metrics exposed by the services. metrics exposed by the services.
- Tempo will receive OTLP traces via the OpenTelemetry collector when added. - Tracing may be added later as a separate feature evaluated against the
passive-collection principle.
- Starter dashboards for queue depth, job latency, LLM tokens/min, refund rate, - Starter dashboards for queue depth, job latency, LLM tokens/min, refund rate,
and 429/fallback rate will ship with the metrics/traces datasources. and 429/fallback rate will ship with the metrics datasources.
### OpenObserve + OTel Collector (`observer` profile) ### OpenObserve + Vector (`observer` profile)
- `otel-collector` runs in the `observer` profile and listens for OTLP on - `vector` runs in the `observer` profile. It reads container logs from the
`4317` (gRPC) and `4318` (HTTP). local Docker socket and scrapes Prometheus `/metrics` endpoints inside the
- Services export OTLP traces, metrics, and logs to `http://otel-collector:4318` compose network (`api:8000/metrics`, workers on `:9101`/`:9102`/`:9104`/`:9105`/`:9103`).
(`OTEL_EXPORTER_OTLP_ENDPOINT`). - Vector enriches logs with `service` (container name) and preserves
- The collector also scrapes Prometheus `/metrics` from `api:9100` and each `correlation_id` from JSON log lines.
worker (`:9101`/`:9102`/`:9104`/`:9105`/`:9103`). - Logs are sent to
- `otlphttp/openobserve` exporter forwards logs and traces to `http://openobserve:5080/openobserve/api/default/contract_check/_json` and
`http://openobserve:5080/openobserve/api/default` using `OPENOBSERVE_AUTH_TOKEN` metrics to `http://openobserve:5080/openobserve/api/default/prometheus/api/v1/write`,
(the `/openobserve` prefix matches `ZO_BASE_URI`). both authenticated with the OpenObserve root credentials
- `prometheusremotewrite/openobserve` exporter forwards metrics to (`OPENOBSERVE_ROOT_USER_EMAIL` / `OPENOBSERVE_ROOT_USER_PASSWORD`).
`http://openobserve:5080/openobserve/api/default/prometheus/api/v1/write`
because OpenObserve ingests metrics through the Prometheus remote-write path.
- OpenObserve UI is exposed under `/openobserve/` via nginx. - OpenObserve UI is exposed under `/openobserve/` via nginx.
- Worker metrics ports are no longer published on the Docker host; they are only
reachable inside the compose network for scraping.
- The API metrics port is bound to `127.0.0.1` by default to avoid accidental
public exposure.
### OpenTelemetry (`core/telemetry.py`) ### Telemetry module (`core/telemetry.py`)
- `setup_telemetry()` / `shutdown_telemetry()` are safe no-op stubs. The
application does not import or initialize the OpenTelemetry SDK.
- `configure_logging` routes structlog through a root `StreamHandler`, so logs
reach stdout/stderr and are collected passively by Vector or Promtail.
- OTLP exporter sends traces, metrics, and logs to the endpoint configured in
`OTEL_EXPORTER_OTLP_ENDPOINT` (collector in `observer` profile, Tempo in `obs`).
- Auto-instrument FastAPI (api), httpx (all outbound, incl. Ollama calls).
- `configure_logging` routes structlog through stdlib logging so the OTEL log
handler captures application logs with their structured fields.
- Spans carry the same `correlation_id` as logs. Trace from HTTP request →
RabbitMQ publish → consume → LLM call is one trace tree.
--- ---
@ -1690,7 +1689,7 @@ No business logic, no direct state. The hexagonal rule.
- **One image = one entrypoint**; `MODE` dispatcher is dead. - **One image = one entrypoint**; `MODE` dispatcher is dead.
- **Publisher confirms** on; a published-but-unconfirmed message fails the - **Publisher confirms** on; a published-but-unconfirmed message fails the
HTTP request. HTTP request.
- **Correlation ID** flows HTTP → message header → logs → traces. - **Correlation ID** flows HTTP → message header → logs.
--- ---
@ -1757,7 +1756,7 @@ Each step is a verifiable unit. Do not start step N+1 until N is green
- ЮKassa + `invoices` logic + recurring (separate effort). - ЮKassa + `invoices` logic + recurring (separate effort).
- React SPA + Telegram Login + SSE/webhook delivery. - React SPA + Telegram Login + SSE/webhook delivery.
- Grafana dashboards polished; Tempo/Jaeger trace UI. - Grafana dashboards polished.
--- ---

View file

@ -0,0 +1,363 @@
# ARCHITECTURE (AS BUILT) — «Контракт-чек»
> This document describes the system **as it exists in the code today**, verified
> against `src/`, `migrations/`, `tests/`, and compose files. The design-time
> rationale, decision history and future steps remain in
> [`docs/ARCHITECTURE.md`](ARCHITECTURE.md) (design doc); this file is the
> as-built map. Domain language is defined in the root [`CONTEXT.md`](../CONTEXT.md).
---
## 1. What the system is
LLM-powered screening of contract (deal) documents under Russian / Belarusian
civil law. A user (Telegram bot, web JWT auth, or B2B API key) uploads a
document; an event-driven pipeline extracts text, prescreens it, optionally
runs deep LLM analysis, and produces a markdown report with findings, quotes
and clause references. Document consumption is metered: subscription Quota
first, then prepaid Credits, with ЮKassa payments (topups, subscriptions,
renewals, refunds with clawback).
**Stack:** Python 3.14, uv + hatchling, FastAPI, SQLAlchemy 2 (async) +
Alembic, aio-pika (RabbitMQ), MinIO (S3), Redis, aiogram 3, httpx,
Prometheus + structlog + Sentry, OpenObserve/Vector (or Grafana/Loki) for
observability, Nginx + certbot at the edge. Docker Compose deployment
(one Dockerfile per service in `srv/`), k8s-ready but not k8s-running.
---
## 2. Hexagonal shape and import rules
```
src/contract_check/
├── core/ # domain — owns ALL state and side effects
│ ├── db/ # models, session, repositories/
│ ├── mq/ # topology, publisher, consumer, messages, management
│ ├── s3/ # Storage port + MinIO adapter
│ ├── llm/ # LLMProvider port + ollama_cloud / yandex_gpt adapters
│ ├── billing/ # PaymentProvider port + yookassa, quota, fulfillment, refunds
│ ├── extraction/ # DocumentExtractor port + format adapters
│ ├── analysis/ # chunker, checklist, analyzer, report_schema
│ ├── notifications/ # SMTP transport + notify publisher
│ ├── security/ # argon2 passwords
│ └── config.py logging.py metrics.py telemetry.py sentry.py errors.py
│ credits.py tokens.py api_keys.py rate_limit.py auth*.py passkeys.py
│ redis_client.py review/
├── api/ # FastAPI image (routes/, admin/, billing/ pay pages, schemas/)
├── worker_extract/ # CPU image: pymupdf + mammoth + striprtf + chardet + tesseract
├── worker_prescreen/# hybrid metadata extraction + routing
├── worker_analyze/ # I/O image: LLM provider
├── worker_notify/ # email (aiosmtplib)
├── worker_billing/ # timer scheduler (no MQ)
└── bot/ # aiogram adapter — HTTP-only
```
**Boundary rules (statically enforced for the bot by
`tests/unit/test_bot_boundary.py`):**
- `core/*` may import anything.
- `api/*`, `worker_*/*` import only `core/*` (+ their own modules).
- `bot/*` imports only `httpx`/`aiohttp`/aiogram and its own modules — never
`core.db`, `core.s3`, `core.llm`, `core.mq`, `core.credits`. It talks to the
api over HTTP exclusively, and may run on a separate host
(`docker-compose.bot.yml`).
**Control plane:** `api` is the only writer for user-initiated mutations
(upload → reserve Document Slot → MinIO → publish). Workers write their own
stage rows / reports; credits move only through the idempotent compensation
helpers (`core/billing/quota.compensate_document_slot`, `core/credits.refund_credit`).
---
## 3. Services and the pipeline
```
Telegram ──► bot (aiogram, HTTP-only) ──HTTP──► api (FastAPI) ──publish──► RabbitMQ
owns: PG, MinIO, Redis, │
quota/credits, tokens ▼
extract.q ──► worker-extract
(CPU: pymupdf/mammoth/tesseract)
│ publish
prescreen.q ──► worker-prescreen
(hybrid heuristic + optional LLM)
│ deep_analysis │ manual_review (terminal)
▼ ▼
analyze.q ──► worker-analyze ──► Postgres (Report, done)
notify.x ──► notify.q ──► worker-notify (SMTP: reset, magic link, …)
worker-billing (60 s Postgres-advisory-lock tick; no MQ)
```
### api (`api/`, `srv/api/`)
- `create_app` (`api/app.py:115`): logging/Sentry, MinIO `ensure_bucket`,
MQ publisher (publisher confirms), Redis rate-limiter with in-memory
fallback, admin seed. Middleware: CORS, correlation-id propagation,
`http_request_duration`, access logs, redacted debug payloads, generic 500.
- Routes under `/api/v1`: `auth/` (Telegram bot/web/miniapp, email+password
JWT pair, passkeys, magic links, support), `documents` (upload → 202),
`reports` (polling + SSE `/events`), `me` (profile, overview, documents,
telegram bind, password), `billing` (plans, checkout, invoices,
subscription, autorenew, refund), `b2b` (API-key analyze/status/usage/
profile/keys CRUD), `webhooks/yookassa`. Plus `/healthz`, `/readyz`,
`/metrics` (bearer-gated), server-rendered `/pay/{invoice_id}` (short-lived
JWT pay token), and `/admin/*` (Jinja2 + HTMX panel: users, credits,
invoices/refunds, hold clearing, gift subscriptions, manual-review queue,
MQ queues/DLQ requeue).
- **Upload flow** (`api/services.py:56` `upload_and_enqueue`): validate
suffix/format → reject on `billing_hold` (402) → stream body with size cap
(413) → MinIO put (DB failure triggers best-effort S3 cleanup) → insert
`documents` (queued) + `jobs` (extract/pending) → reserve Document Slot
(Quota-then-Credit when `PLANS_ENABLED`, else legacy `reserve_credit`; 402
on `NoCredits`) → publish `DocumentUploaded` (publisher confirms; on MQ
failure: release slot / refund credit, status `publish_failed`) →
`202 {document_id, correlation_id, credits_left}`.
### worker-extract (`worker_extract/`, metrics :9101, prefetch 1)
Consumes `extract.q` / `DocumentUploaded`. Idempotency check against terminal
statuses → status `extracting` → download blob → `extract_document`
(`core/extraction/factory.py`: python-magic sniff → uploader MIME → suffix;
adapters: pdf/pymupdf, docx/mammoth, rtf/striprtf, txt+csv/chardet,
images/tesseract OCR; unsupported formats are terminal + refunded) → upload
extracted markdown to MinIO → publish `PrescreenRequested` (or
`DocumentExtracted` straight to analyze.q when `PRESCREEN_ENABLED=false`).
Failure classes: `extraction_failed` (non-refundable under `infra_only`),
`ocr_failed`, `infra`. Terminal failure → DLQ + slot compensation.
### worker-prescreen (`worker_prescreen/`, metrics :9104, prefetch 1)
Consumes `prescreen.q` / `PrescreenRequested`. Handler
(`worker_prescreen/handler.py:116`): transition `prescreening` → download
text → **hybrid extractor** (`extractor_hybrid.py`): Stage 1 deterministic
heuristic (`heuristic-v2`, regex-free, keyword/positional); Stage 2 LLM
fallback only if enabled AND confidence < `PRESCREEN_LLM_FALLBACK_THRESHOLD`
(LLM failure never fatal; LLM fills heuristic `None`s, booleans OR-merged,
confidence rescored) → **router** (`router.py:30`): missing
type/parties/low-confidence → `manual_review` (terminal status, admin queue);
amount ≥ `PRESCREEN_HIGH_VALUE_THRESHOLD` or penalty/arbitration clause →
`deep_analysis` (publish `AnalyzeRequested` with `prescreen_meta`);
`auto_approve` disabled by default (remapped to manual_review; when enabled
writes a lightweight Report, `status=done`). Persists `prescreen_results`.
### worker-analyze (`worker_analyze/`, metrics :9102, prefetch 3)
Consumes `analyze.q` / `AnalyzeRequested`. Status `analyzing/llm` → download
text → `provider.analyze(text, checklist)` (chunking, fan-out under
`asyncio.Semaphore(LLM_MAX_CONCURRENCY)`, json-schema + repair loop, 429 →
fallback model — all inside the LLM adapter) → `render_markdown` (findings,
quotes, clause refs, «не заменяет юриста» disclaimer) → `reports` upsert,
`status=done`. Failure classes: `llm_quota`, `llm_timeout`,
`llm_invalid_output`, `infra`; DLQ → slot compensation.
### worker-notify (`worker_notify/`, metrics :9103, prefetch 5)
Consumes `notify.q` / `NotificationMessage` (kinds: password_reset, welcome,
email_verification, magic_link). SMTP via aiosmtplib; dev logger when
`SMTP_HOST` empty.
### worker-billing (`worker_billing/`, metrics :9105, no MQ)
60-second tick guarded by a **Postgres advisory lock** (single replica):
renewal invoices for auto-renew subscriptions expiring < 3 days; period roll /
`past_due` (grace) / `expired` transitions; reconciliation of pending
invoices older than 15 minutes via the same `apply_payment_status` used by
the webhook. No-op safe when ЮKassa disabled.
### bot (`bot/`, `srv/bot/`)
aiogram 3, polling or webhook mode (aiohttp, secret-token auth). Commands:
`/start /help /profile /balance /plans /reports /status`; checkout callbacks
(`plan:*`, `topup:*`); document upload → API → poll report with stage labels;
per-user rate limit (Redis, memory fallback); service-token login cached per
`telegram_id`; `ensure_disclaimer` guarantees the legal disclaimer.
---
## 4. RabbitMQ topology (declared idempotently in `core/mq/topology.py`)
| Object | Type / args | Purpose |
|---|---|---|
| `contracts.x` | direct | main exchange; RK `extract` / `prescreen` / `analyze` |
| `extract.q` / `prescreen.q` / `analyze.q` | **quorum**, DLX → `contracts.retry.x` | main work queues |
| `contracts.retry.x` + `*.retry.q` | direct + classic (per-message TTL, `lazy` policy via mgmt API) | delay slots; TTL expiry dead-letters the message back to `contracts.x` |
| `extract.dlq` / `prescreen.dlq` / `analyze.dlq` | quorum | poison; manual requeue via admin panel / `core/mq/management.py` |
| `notify.x` / `notify.q` / `notify.retry.q` / `notify.dlq` | mirror of the above | notification pipeline |
**Retry mechanics** (`core/mq/consumer.py`): generic `Consumer[MsgT]` with
`handle`/`classify`/`on_failure`/`on_dlq` hooks. On failure the consumer
re-publishes to the retry exchange with `expiration = MQ_RETRY_BASE_MS ·
2^(attempt1)` (default 2 s base → 2/4/8/16/32 s) and acks the original.
`x-attempt >= MQ_MAX_ATTEMPTS` (5) → DLQ with `x-failure-class` headers,
`jobs.dlq=true`, `documents.status=failed`, slot compensation. Pydantic
validation failure → straight to DLQ (`infra` poison). `core.errors.TerminalError`
bypasses retries. If the failure hooks themselves raise, the message is
retried (hook-failure fallback). Publisher uses **publisher confirms**
unconfirmed publish fails the request, so a paid slot never silently vanishes.
Message schemas (`core/mq/messages.py`, pydantic v2, base `PipelineMessage`
with `next_attempt()`): `DocumentUploaded`, `DocumentExtracted`,
`PrescreenRequested`, `AnalyzeRequested` (carries `prescreen_meta`),
`PrescreenCompleted`, `NotificationMessage`. Headers: `x-correlation-id`,
`x-attempt`, `x-origin`; `delivery_mode=2`.
**Idempotency:** every handler re-reads `documents.status` first; terminal or
in-flight → ack and exit. No double-LLM, no double-refund.
---
## 5. Data model (Postgres, 16 tables, `core/db/models.py`; migrations 00010011)
| Table | Role |
|---|---|
| `users` | identity (telegram_id and/or email), argon2 `password_hash`, reset/magic-link token hashes, `role` (user/admin), `credits_left` (CHECK ≥ 0), `billing_hold` |
| `passkey_credentials` | WebAuthn credentials (credential_id, public_key, sign_count) |
| `documents` | one upload: s3 keys, status `queued→extracting→prescreening→ocr→analyzing→done\|failed\|manual_review`, `refunded` |
| `reports` | 1:1 with document: `content_json` JSONB + `markdown`, model, tokens, latency, prescreen link |
| `prescreen_results` | extracted metadata (type, parties, amount, dates, clause flags), `confidence_score`, `routing_decision`, `extractor_version` |
| `jobs` | per-stage correlation (UNIQUE document+queue), attempts, `last_failure_class`, `dlq` |
| `service_tokens` | per-adapter bearer auth (bot/web/cli), revocable |
| `api_keys` / `api_key_requests` | B2B keys (SHA-256 hash, per-key rps + monthly quota) and per-call usage ledger |
| `invoices` | money in **integer kopecks**; kinds `topup`/`subscription`/`renewal`; statuses draft→pending→succeeded/cancelled/refunded |
| `plans` / `subscriptions` / `quota_usage` | seeded plan catalog; one active-or-past_due subscription per user (partial unique); quota ledger with UNIQUE(document_id) idempotency |
| `credit_events` | append-only credit ledger (`delta ≠ 0`, `balance_after ≥ 0` self-verifying) |
| `user_profiles` | 1:1 passive settings (language, TZ, notif/dashboard prefs) |
**Data access:** all SQL lives in `core/db/repositories/` (14 repositories);
a repository receives an `AsyncSession` and never commits — callers own
transactions. Raw `text()` only inside repositories/migrations. Status/enum
columns are `TEXT + CHECK` (not PG enums) so migrations stay additive. Alembic
is async; CI exercises `upgrade head``downgrade base`.
---
## 6. Credits, Quota, refunds (billing invariants, `core/billing/`)
- **Reserve-on-enqueue:** the API synchronously reserves a Document Slot
before publishing (`core/billing/quota.py:22`
`reserve_document_slot`): `SELECT … FOR UPDATE` on the active subscription →
quota rows this period < `plan.monthly_quota` → insert `quota_usage`
(`ON CONFLICT DO NOTHING`) → source `quota`; else atomic credit reserve →
`credits`; else 402. `PLANS_ENABLED=false` → legacy credits-only path.
- **Compensation (exactly-once):** on terminal processing failure,
`compensate_document_slot` (`quota.py:100`) releases the quota row or
refund the credit (guarded by `documents.refunded`), honouring
`REFUND_POLICY=all|infra_only` with the failure-class taxonomy
(`extraction_failed` non-refundable under `infra_only`).
- **Payments:** `PaymentProvider` port (`port.py`) + `YookassaProvider`
(httpx, Basic auth, Idempotence-Key = invoice UUID, kopecks↔RUB only at
the HTTP boundary). `YOOKASSA_ENABLED=false``PaymentsDisabled`
billing mutations 503, catalog readable.
- **Webhook trust** (`api/routes/webhooks.py`): Basic auth constant-time
check, then the payment is **re-fetched via REST** — the push payload's
amount is never trusted; fulfillment is idempotent
(`core/billing/fulfillment.apply_payment_status`), also used by the
reconciliation sweep.
- **Refunds (D10)** (`core/billing/refunds.py`): within `REFUND_WINDOW_DAYS`
(14) and usage ≤ 20 % of purchased → full; else proportional
`max(0, amount used × PRICE_PER_DOC)`; otherwise 409. Execution:
provider refund → invoice `refunded`**clawback** (unspent credits /
subscription + period quota rows) → `users.billing_hold = TRUE` when the
balance would go negative; uploads 402 until an admin clears the hold.
---
## 7. LLM provider port (`core/llm/`)
`LLMProvider` Protocol (`port.py:31`): `analyze(text, *, checklist)` and
`extract_prescreen(text)`. Registered providers via `LLM_PROVIDER`:
`ollama_cloud` (default; `format: json-schema` chat, model
`qwen2.5:14b`, fallback `qwen2.5:7b`) and `yandex_gpt`
(`yandexgpt-lite`, `Api-Key`, JSON_OBJECT). Shared behaviour in each adapter:
chunk fan-out under a semaphore, repair loop (one REPAIR re-send on invalid
JSON, then `LLMError`), 429 → fallback model → `LLMQuotaError`, exponential
HTTP backoff for timeouts/5xx, terminal `LLMConfigError` for connect/404.
Checklist: 10 frozen items (`core/analysis/checklist.py`); `Finding` schema
(`report_schema.py`) doubles as the Ollama json-schema and tolerates alias
field names.
---
## 8. AuthN/AuthZ surface
| Caller | Mechanism |
|---|---|
| bot adapter | `Authorization: Bearer <service_token>` (only `/api/v1/auth/telegram/bot`) → user JWT exchange |
| Telegram web / Mini App | Login Widget hash / `initData` verification → JWT |
| web user | email+password → JWT pair (access 24 h + refresh 30 d, Redis-backed revocation); passkeys (WebAuthn, Redis challenges) and magic links → single access JWT |
| B2B | `X-API-Key` (SHA-256, token-bucket rate limit, monthly quota) |
| admin panel | HttpOnly cookie + `users.role = admin` |
| ЮKassa webhook | Basic (shopId:secret) |
| health/metrics | none / bearer |
Redis is used only for rate limiting, refresh-token store, passkey
challenges and bot limiter — never as a job queue.
---
## 9. Storage (MinIO, `core/s3/`)
Single bucket `contract-check-docs`; keys `users/{uid}/docs/{did}.{ext}`
(original) and `users/{uid}/docs/{did}.txt` (extracted markdown), built only
via key builders. Uploads proxy through the API (multipart, 25 MiB cap,
streamed with limit). `minio-init` one-shot creates the bucket and the ILM
expiry (`DOC_RETENTION_DAYS` 7 / `TEXT_RETENTION_DAYS` 30) — 152-ФЗ lever:
raw text leaves on schedule; reports live in Postgres and survive the purge.
---
## 10. Observability & deploy
- **Logs/metrics/traces:** structlog JSON + correlation-id contextvar
(propagated through HTTP headers and MQ headers), Prometheus per-service
(`/metrics` on api :9100; workers :9101:9105 scraped in-network by
Vector), Sentry (DSN-gated). Passive collection: `observer` profile =
OpenObserve + Vector; `obs` profile = Prometheus + Loki + Promtail +
Grafana. OTLP push was removed.
- **Compose** (`docker-compose.yml`): default = infra only (postgres:18 with
`wal_level=replica` + WAL archive, redis:8, rabbitmq:4, minio + minio-init);
profiles `services` (api + 5 workers), `bot` (standalone-able),
`edge` (nginx + certbot + exporter), `observer`/`obs`. Host ports offset:
PG 15432, Redis 17379, Rabbit 5672/15672, MinIO 9000/9001, api 8000,
OpenObserve 5080, edge 80/443.
- **Images:** one Dockerfile per service in `srv/` (two-stage uv build,
`uv sync --no-default-groups --group <grp>` → python:3.14-slim); dependency
groups (PEP 735) per service keep images minimal; bot is leanest.
- **CI** (`.github/workflows/ci.yml`): ruff check+format, `ty` typecheck,
unit tests with `--cov-fail-under=50`, integration tests against an
isolated compose test stack (`docker-compose.test.yml`, offset ports).
---
## 11. Testing
- **Unit** (`tests/unit/`, ~40 files / 326 tests): no infra; respx for LLM
HTTP; includes `test_bot_boundary.py` (AST check of the hexagonal bot rule).
- **Integration** (`tests/integration/`, 21 files / 134 tests, `-m
integration`): full docker-compose test stack, alembic upgrade, seeded
service token, ASGI httpx + LifespanManager; covers upload pipeline, each
worker, credits/quota DB, auth flows, passkeys/magic links, B2B, admin
panel, billing checkout/webhook/pay page, subscriptions, refunds.
- `tests/conftest.py` deduplicates `src.contract_check.*` vs
`contract_check.*` module loading (avoids duplicate Prometheus timeseries).
---
## 12. Cross-cutting conventions
1. Money is integer kopecks everywhere; decimals exist only at the ЮKassa
HTTP boundary.
2. Enum-ish columns are `TEXT + CHECK`; migrations additive and hand-written.
3. Repositories never commit; callers own transactions.
4. Every queue handler is idempotent via `documents.status` re-read;
refunds/slot releases are exactly-once via guards.
5. Publisher confirms on every publish that follows a paid reservation.
6. External providers (LLM, payments, storage) sit behind ports in `core/`;
adapters are swappable and respx-testable.
7. Feature degrade switches: `PLANS_ENABLED`, `YOOKASSA_ENABLED`,
`PRESCREEN_ENABLED`, `PRESCREEN_LLM_FALLBACK_ENABLED`,
`SMTP_HOST` (empty = dev logger), `REFUND_POLICY`.
8. Domain language (Plan/Subscription/Quota/Credits/Topup/Invoice/Renewal/
Saved Payment Method/Dunning/Signup Bonus/ЕРИП/Billing Hold/Clawback/
Document Slot) is normative — see root `CONTEXT.md`; ADRs live in
`docs/adr/`.

View file

@ -144,9 +144,11 @@ PRICE_PER_DOC_KOPECKS=19900 # 199 ₽ за документ без подпис
# --- Observability (опционально) --- # --- Observability (опционально) ---
SENTRY_DSN=https://...@sentry.io/... SENTRY_DSN=https://...@sentry.io/...
# OTLP/HTTP endpoint. With profile `observer` use the local collector. # Vector (профиль `observer`) авторизуется в OpenObserve с этими кредами
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 # (совпадают с root-пользователем OpenObserve).
OPENOBSERVE_AUTH_TOKEN=cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM= OPENOBSERVE_ROOT_USER_EMAIL=root@example.com
OPENOBSERVE_ROOT_USER_PASSWORD=Complexpass#123
# OTEL_EXPORTER_OTLP_ENDPOINT больше не используется: приложение не шлёт OTLP.
``` ```
> Полный список: см. `.env.example` и `ARCHITECTURE.md §11`. Включение > Полный список: см. `.env.example` и `ARCHITECTURE.md §11`. Включение
@ -201,7 +203,7 @@ make shell-db # UPDATE plans SET price_kopecks = 59000 WHERE code = 'lite';
| `services` | `api`, `worker-extract`, `worker-prescreen`, `worker-analyze`, `worker-billing`, `worker-notify` | | `services` | `api`, `worker-extract`, `worker-prescreen`, `worker-analyze`, `worker-billing`, `worker-notify` |
| `bot` | `bot` — Telegram-адаптер; можно поднять на этом же хосте или на отдельном сервере | | `bot` | `bot` — Telegram-адаптер; можно поднять на этом же хосте или на отдельном сервере |
| `obs` | `loki`, `promtail`, `grafana`, `prometheus` (logs + metrics; traces — позже) | | `obs` | `loki`, `promtail`, `grafana`, `prometheus` (logs + metrics; traces — позже) |
| `observer` | `openobserve`, `otel-collector` (logs + metrics + traces через collector) | | `observer` | `openobserve`, `vector` (logs + metrics; приложение не шлёт OTLP) |
| `edge` | `nginx`, `certbot` | | `edge` | `nginx`, `certbot` |
```bash ```bash
@ -220,7 +222,7 @@ docker compose --profile services --profile bot up -d --build
# + observability Grafana/Loki/Prometheus (логи + метрики): # + observability Grafana/Loki/Prometheus (логи + метрики):
docker compose --profile services --profile bot --profile obs up -d --build docker compose --profile services --profile bot --profile obs up -d --build
# + observability OpenObserve (логи + метрики + трейсы через otel-collector): # + observability OpenObserve (логи + метрики через Vector; приложение не шлёт OTLP):
docker compose --profile services --profile bot --profile observer up -d --build docker compose --profile services --profile bot --profile observer up -d --build
# + edge (nginx reverse proxy + TLS; deploy/nginx готов): # + edge (nginx reverse proxy + TLS; deploy/nginx готов):
@ -305,7 +307,9 @@ docker compose --profile bot logs -f bot
```bash ```bash
curl http://localhost:8000/healthz # liveness — 200 curl http://localhost:8000/healthz # liveness — 200
curl http://localhost:8000/readyz # readiness — 200 (PG + Rabbit + MinIO) curl http://localhost:8000/readyz # readiness — 200 (PG + Rabbit + MinIO)
curl http://localhost:8000/metrics # Prometheus exposition # /metrics доступен на loopback по умолчанию; снаружи хоста — только через
# edge/nginx или по туннелю. Для проверки внутри контейнера:
docker compose exec api curl -s http://localhost:8000/metrics
``` ```
### 6.2 RabbitMQ Management UI ### 6.2 RabbitMQ Management UI
@ -630,7 +634,8 @@ curl https://contract-check.example.com/healthz
По умолчанию `nginx.conf` проксирует: По умолчанию `nginx.conf` проксирует:
- `/api/v1/*`, `/admin/*` - `/api/v1/*`, `/admin/*`
- `/healthz`, `/readyz` - `/healthz`, `/readyz`
- `/metrics` (открыт наружу; закройте файрволом или уберите приватный скрейпинг) - `/metrics` → проксируется наружу; настройте `METRICS_BEARER_TOKEN` и ограничьте доступ
файрволом/edge, чтобы метрики не утекали наружу. Worker-метрики доступны только внутри сети compose.
- `/webhook/*``/api/v1/webhooks/` - `/webhook/*``/api/v1/webhooks/`
- `${BOT_WEBHOOK_PATH}``bot:8080` (Telegram-бот в webhook-режиме, профиль `bot`; см. §14.4) - `${BOT_WEBHOOK_PATH}``bot:8080` (Telegram-бот в webhook-режиме, профиль `bot`; см. §14.4)
- `/grafana/*``grafana:3000` (когда поднят профиль `obs`) - `/grafana/*``grafana:3000` (когда поднят профиль `obs`)

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,60 @@
{
"schemaVersion": 1,
"ok": false,
"command": "visual-check",
"evidenceKind": "automated-browser",
"status": "skipped",
"visualReview": "pending",
"artifact": {
"path": "/home/san/PythonProjects/DealDocumentScreening/docs/diagrams/contract-check-architecture.html",
"sha256": "14b219ba1da1f4525e59338b04dadf208ade2b6bdc30eb779875c9842364f0c7",
"bytes": 846404
},
"state": {
"detail": "read",
"motion": "still"
},
"chrome": {
"status": "unavailable",
"executable": null
},
"diagnostics": [
{
"code": "viewer/chrome-unavailable",
"severity": "warning",
"message": "Chrome or Chromium is unavailable. Set ARCHIFY_CHROME to its executable path.",
"subject": {
"artifact": "/home/san/PythonProjects/DealDocumentScreening/docs/diagrams/contract-check-architecture.html"
},
"evidence": {
"executable": null
},
"supportedFixes": [
"set ARCHIFY_CHROME to a Chrome or Chromium executable and rerun visual-check"
]
}
],
"containment": {
"status": "skipped",
"viewports": []
},
"readability": {
"status": "skipped",
"minimumProjectedNodeTextPx": 6,
"viewports": []
},
"viewerChrome": {
"status": "skipped",
"viewports": []
},
"captures": {
"status": "skipped",
"screenshots": [],
"contactSheet": null
},
"sidecars": {
"receipt": "contract-check-architecture.visual-check.json",
"contactSheet": "contract-check-architecture.visual-check.html"
},
"error": "Chrome or Chromium is unavailable. Set ARCHIFY_CHROME to its executable path."
}

View file

@ -7,7 +7,7 @@ name = "contract-check"
version = "0.1.0" version = "0.1.0"
description = "AI-скрининг рисков в договорах (PDF/DOCX) для СНГ — ГК РФ / ГК РБ. Event-driven production app." description = "AI-скрининг рисков в договорах (PDF/DOCX) для СНГ — ГК РФ / ГК РБ. Event-driven production app."
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.14"
license = { text = "Proprietary" } license = { text = "Proprietary" }
authors = [{ name = "Контракт-чек" }] authors = [{ name = "Контракт-чек" }]
keywords = ["legal", "contracts", "llm", "risk-screening", "fastapi", "rabbitmq"] keywords = ["legal", "contracts", "llm", "risk-screening", "fastapi", "rabbitmq"]
@ -32,7 +32,7 @@ packages = ["src/contract_check"]
# #
# api → core + db/mq/s3/obs + fastapi/uvicorn # api → core + db/mq/s3/obs + fastapi/uvicorn
# worker-extract → core + db/mq/s3/obs + pymupdf/tesseract # worker-extract → core + db/mq/s3/obs + pymupdf/tesseract
# worker-analyze → core + db/mq/s3/obs + otel-httpx # worker-analyze → core + db/mq/s3/obs
# bot → core + aiogram (leanest image: no db/mq/s3/llm/tesseract) # bot → core + aiogram (leanest image: no db/mq/s3/llm/tesseract)
# dev → everything needed to lint/typecheck/test locally # dev → everything needed to lint/typecheck/test locally
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@ -51,8 +51,6 @@ s3 = [
obs = [ obs = [
"prometheus-client>=0.20", "prometheus-client>=0.20",
"sentry-sdk>=2", "sentry-sdk>=2",
"opentelemetry-sdk>=1.24",
"opentelemetry-exporter-otlp>=1.24",
] ]
api = [ api = [
{ include-group = "db" }, { include-group = "db" },
@ -68,9 +66,6 @@ api = [
"argon2-cffi>=23.1", "argon2-cffi>=23.1",
"email-validator>=2.1", "email-validator>=2.1",
"webauthn>=2.5", "webauthn>=2.5",
"opentelemetry-instrumentation-fastapi>=0.45b0",
"opentelemetry-instrumentation-asgi>=0.45b0",
"opentelemetry-instrumentation-httpx>=0.45b0",
] ]
extract = [ extract = [
{ include-group = "db" }, { include-group = "db" },
@ -84,21 +79,18 @@ extract = [
"striprtf>=0.0.26", "striprtf>=0.0.26",
"chardet>=5.2", "chardet>=5.2",
"python-magic>=0.4.27", "python-magic>=0.4.27",
"opentelemetry-instrumentation-httpx>=0.45b0",
] ]
analyze = [ analyze = [
{ include-group = "db" }, { include-group = "db" },
{ include-group = "mq" }, { include-group = "mq" },
{ include-group = "s3" }, { include-group = "s3" },
{ include-group = "obs" }, { include-group = "obs" },
"opentelemetry-instrumentation-httpx>=0.45b0",
] ]
prescreen = [ prescreen = [
{ include-group = "db" }, { include-group = "db" },
{ include-group = "mq" }, { include-group = "mq" },
{ include-group = "s3" }, { include-group = "s3" },
{ include-group = "obs" }, { include-group = "obs" },
"opentelemetry-instrumentation-httpx>=0.45b0",
] ]
bot = [ bot = [
"aiogram>=3.4", "aiogram>=3.4",
@ -112,12 +104,10 @@ notify = [
{ include-group = "mq" }, { include-group = "mq" },
{ include-group = "obs" }, { include-group = "obs" },
"aiosmtplib>=3.0", "aiosmtplib>=3.0",
"opentelemetry-instrumentation-httpx>=0.45b0",
] ]
billing = [ billing = [
{ include-group = "db" }, { include-group = "db" },
{ include-group = "obs" }, { include-group = "obs" },
"opentelemetry-instrumentation-httpx>=0.45b0",
"pyjwt>=2.8", "pyjwt>=2.8",
] ]
dev = [ dev = [
@ -142,7 +132,7 @@ dev = [
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
target-version = "py313" target-version = "py314"
src = ["src", "tests"] src = ["src", "tests"]
# Alembic migrations are autogenerated-style history; exclude them from lint+format. # Alembic migrations are autogenerated-style history; exclude them from lint+format.
extend-exclude = ["migrations"] extend-exclude = ["migrations"]

View file

@ -41,7 +41,6 @@ from src.contract_check.core.rate_limit import MemoryRateLimiter, RateLimiter, R
from src.contract_check.core.redis_client import get_redis_client from src.contract_check.core.redis_client import get_redis_client
from src.contract_check.core.s3.minio_storage import MinioStorage from src.contract_check.core.s3.minio_storage import MinioStorage
from src.contract_check.core.sentry import init_sentry from src.contract_check.core.sentry import init_sentry
from src.contract_check.core.telemetry import setup_telemetry, shutdown_telemetry
@asynccontextmanager @asynccontextmanager
@ -55,7 +54,6 @@ async def lifespan(app: FastAPI) -> Any:
) )
bind_context(service="api", env=settings.env) bind_context(service="api", env=settings.env)
init_sentry("api") init_sentry("api")
setup_telemetry("api")
storage = MinioStorage.from_endpoint_url( storage = MinioStorage.from_endpoint_url(
endpoint_url=settings.s3_endpoint_url, endpoint_url=settings.s3_endpoint_url,
@ -112,7 +110,6 @@ async def lifespan(app: FastAPI) -> Any:
await redis_client.aclose() await redis_client.aclose()
except Exception: except Exception:
pass pass
shutdown_telemetry()
def create_app() -> FastAPI: def create_app() -> FastAPI:
@ -147,15 +144,6 @@ def create_app() -> FastAPI:
) -> RedirectResponse: ) -> RedirectResponse:
return RedirectResponse(url=f"/admin/login?reason={exc.reason}", status_code=303) return RedirectResponse(url=f"/admin/login?reason={exc.reason}", status_code=303)
# Emit request traces to the OTLP collector. Must happen after all routes
# and middleware are registered so spans cover the full request lifecycle.
try:
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
FastAPIInstrumentor.instrument_app(app)
except Exception: # pragma: no cover - optional instrumentor
pass
return app return app

View file

@ -72,7 +72,8 @@ Readiness-проба. Выполняет `SELECT 1` в БД.
## metrics ## metrics
Файл: [`metrics.py`](metrics.py). Тег: `metrics`. Без аутентификации. Файл: [`metrics.py`](metrics.py). Тег: `metrics`. Без аутентификации, если
`METRICS_BEARER_TOKEN` не задан; иначе требуется `Authorization: Bearer <token>`.
### `GET /metrics` ### `GET /metrics`

View file

@ -61,7 +61,14 @@ _STAGE_LABELS: dict[str, str] = {
"queued": "Документ принят. В очереди на обработку…", "queued": "Документ принят. В очереди на обработку…",
"extracting": "Извлекаю текст из документа…", "extracting": "Извлекаю текст из документа…",
"ocr": "Документ похож на скан — распознаю страницы (OCR)…", "ocr": "Документ похож на скан — распознаю страницы (OCR)…",
"prescreening": "Предварительная проверка договора…",
"analyzing": "Анализирую риски по чек-листу…", "analyzing": "Анализирую риски по чек-листу…",
"extracting_meta": "Извлекаю реквизиты договора…",
"queued_analyze": "Подготовка к глубокому анализу…",
"manual_review": "Отправлен на ручную проверку",
"auto_approved": "Готово (автопроверка)",
"done": "Готово",
"failed": "Ошибка обработки",
} }
@ -442,7 +449,7 @@ async def cmd_reports(message: Message, api: ApiClient) -> None:
filename = doc.get("filename") or "без имени" filename = doc.get("filename") or "без имени"
status = str(doc.get("status") or "unknown") status = str(doc.get("status") or "unknown")
stage = doc.get("stage") stage = doc.get("stage")
label = _STAGE_LABELS.get(str(stage or status), "Обрабатываю…") label = _stage_label_from(status, stage if isinstance(stage, str) else None)
lines.append(f"\n📄 {filename}\nID: {doc_id}\nСтатус: {label}") lines.append(f"\n📄 {filename}\nID: {doc_id}\nСтатус: {label}")
lines.append("\nДля подробностей: /status <id>") lines.append("\nДля подробностей: /status <id>")
await message.answer("\n".join(lines)) await message.answer("\n".join(lines))
@ -633,8 +640,12 @@ async def _sleep(elapsed: float, timeout: float, interval: float) -> bool:
def _stage_label(report: ReportStatus) -> str: def _stage_label(report: ReportStatus) -> str:
stage = report.stage or report.status return _stage_label_from(report.status, report.stage)
return _STAGE_LABELS.get(stage, _STAGE_LABELS.get(report.status, "Обрабатываю…"))
def _stage_label_from(status: str | None, stage: str | None) -> str:
key = stage or status
return _STAGE_LABELS.get(key, _STAGE_LABELS.get(status, "Обрабатываю…"))
async def _deliver_report( async def _deliver_report(

View file

@ -7,7 +7,7 @@ Adapters (bot) deliberately do NOT import this package beyond config/logging
Subpackages: Subpackages:
config typed env config (pydantic-settings) config typed env config (pydantic-settings)
logging structlog JSON + correlation_id propagation logging structlog JSON + correlation_id propagation
telemetry OpenTelemetry init telemetry no-op stubs; OTLP push removed
sentry Sentry init sentry Sentry init
metrics Prometheus counters/histograms + metrics HTTP server metrics Prometheus counters/histograms + metrics HTTP server
db SQLAlchemy 2 models (6 tables), async session, enums db SQLAlchemy 2 models (6 tables), async session, enums

View file

@ -99,7 +99,6 @@ def _jwt_refresh_ttl() -> dt.timedelta:
def create_access_token(user_id: uuid.UUID, telegram_id: int) -> str: def create_access_token(user_id: uuid.UUID, telegram_id: int) -> str:
"""Sign a fresh access JWT for a verified user.""" """Sign a fresh access JWT for a verified user."""
settings = get_settings()
now = dt.datetime.now(tz=dt.UTC) now = dt.datetime.now(tz=dt.UTC)
claims = AccessTokenClaims( claims = AccessTokenClaims(
sub=user_id, sub=user_id,
@ -111,7 +110,7 @@ def create_access_token(user_id: uuid.UUID, telegram_id: int) -> str:
{ {
"iat": int(now.timestamp()), "iat": int(now.timestamp()),
"exp": int((now + _jwt_access_ttl()).timestamp()), "exp": int((now + _jwt_access_ttl()).timestamp()),
"iss": settings.otel_service_name or "contract-check", "iss": "contract-check",
"aud": "contract-check", "aud": "contract-check",
} }
) )
@ -182,7 +181,6 @@ class RefreshTokenClaims:
def create_refresh_token(user_id: uuid.UUID, jti: str) -> str: def create_refresh_token(user_id: uuid.UUID, jti: str) -> str:
"""Sign a refresh JWT. `jti` is the lookup key in the refresh-token store.""" """Sign a refresh JWT. `jti` is the lookup key in the refresh-token store."""
settings = get_settings()
now = dt.datetime.now(tz=dt.UTC) now = dt.datetime.now(tz=dt.UTC)
claims = RefreshTokenClaims(sub=user_id, jti=jti, type=JWT_TYPE_REFRESH) claims = RefreshTokenClaims(sub=user_id, jti=jti, type=JWT_TYPE_REFRESH)
payload = claims.to_dict() payload = claims.to_dict()
@ -190,7 +188,7 @@ def create_refresh_token(user_id: uuid.UUID, jti: str) -> str:
{ {
"iat": int(now.timestamp()), "iat": int(now.timestamp()),
"exp": int((now + _jwt_refresh_ttl()).timestamp()), "exp": int((now + _jwt_refresh_ttl()).timestamp()),
"iss": settings.otel_service_name or "contract-check", "iss": "contract-check",
"aud": "contract-check", "aud": "contract-check",
} }
) )

View file

@ -86,8 +86,6 @@ class Settings(BaseSettings):
# --- observability (empty disables) --- # --- observability (empty disables) ---
sentry_dsn: str = "" sentry_dsn: str = ""
otel_exporter_otlp_endpoint: str = ""
otel_service_name: str = "contract-check"
# --- LLM provider --- # --- LLM provider ---
llm_provider: str = "ollama_cloud" llm_provider: str = "ollama_cloud"

View file

@ -93,7 +93,7 @@ def safe_body(
if isinstance(body, Mapping | list): if isinstance(body, Mapping | list):
try: try:
text = json.dumps(redact_json(body), ensure_ascii=False, default=str) text = json.dumps(redact_json(body), ensure_ascii=False, default=str)
except (TypeError, ValueError): except TypeError, ValueError:
text = str(body) text = str(body)
return _truncate(text, max_chars) return _truncate(text, max_chars)
if isinstance(body, bytes): if isinstance(body, bytes):
@ -147,13 +147,20 @@ def http_log_event_hooks(
request.extensions[started_key] = time.perf_counter() request.extensions[started_key] = time.perf_counter()
if not is_debug_enabled(): if not is_debug_enabled():
return return
try:
body: bytes | str | None = request.content
except httpx.RequestNotRead:
# Streaming bodies (e.g. multipart file uploads) are never loaded
# into memory, so there is nothing to inspect without consuming
# the stream the actual send needs. Summarize instead of crashing.
body = "<streaming body not read>"
log.debug( log.debug(
"http_request", "http_request",
service=service, service=service,
method=request.method, method=request.method,
url=str(request.url), url=str(request.url),
headers=redact_headers(request.headers), headers=redact_headers(request.headers),
body=safe_body(request.content, request.headers.get("content-type"), max_payload_chars), body=safe_body(body, request.headers.get("content-type"), max_payload_chars),
) )
async def _log_response(response: httpx.Response) -> None: async def _log_response(response: httpx.Response) -> None:

View file

@ -183,8 +183,8 @@ def configure_logging(
logger_factory=structlog.stdlib.LoggerFactory(), logger_factory=structlog.stdlib.LoggerFactory(),
cache_logger_on_first_use=True, cache_logger_on_first_use=True,
) )
# Route stdlib logging through structlog so third-party libs match our format, # Route stdlib logging through structlog so third-party libs match our format.
# and structlog events reach any OTEL handlers attached to the root logger. # Structlog events always reach the root StreamHandler configured below.
stdlib_handler = logging.StreamHandler(sys.stderr) stdlib_handler = logging.StreamHandler(sys.stderr)
stdlib_handler.setFormatter( stdlib_handler.setFormatter(
structlog.stdlib.ProcessorFormatter( structlog.stdlib.ProcessorFormatter(

View file

@ -13,7 +13,17 @@ def get_redis_client(redis_url: str) -> Any:
Callers own the connection lifecycle. The api creates one client at startup Callers own the connection lifecycle. The api creates one client at startup
and stores it in app.state.redis. and stores it in app.state.redis.
Maintenance notifications are disabled: they are a Redis Cloud feature and
self-hosted/Valkey servers reject ``CLIENT MAINT_NOTIFICATIONS`` with an
``unknown subcommand`` error, spamming debug logs.
""" """
from redis.asyncio import Redis as AsyncRedis from redis.asyncio import Redis as AsyncRedis
from redis.maint_notifications import MaintNotificationsConfig
return AsyncRedis.from_url(redis_url, decode_responses=True) maint_config = MaintNotificationsConfig(enabled=False)
return AsyncRedis.from_url(
redis_url,
decode_responses=True,
maint_notifications_config=maint_config,
)

View file

@ -48,7 +48,7 @@ def verify_password(plain: str, hashed: str) -> bool:
return _hasher.verify(hashed, plain) return _hasher.verify(hashed, plain)
except VerifyMismatchError: except VerifyMismatchError:
return False return False
except (VerificationError, InvalidHash): except VerificationError, InvalidHash:
return False return False
@ -56,7 +56,7 @@ def needs_rehash(hashed: str) -> bool:
"""True if the stored hash uses outdated params and should be re-hashed on next login.""" """True if the stored hash uses outdated params and should be re-hashed on next login."""
try: try:
return _hasher.check_needs_rehash(hashed) return _hasher.check_needs_rehash(hashed)
except (InvalidHash, TypeError): except InvalidHash, TypeError:
return False return False

View file

@ -28,4 +28,4 @@ def init_sentry(service_name: str | None = None) -> None:
traces_sample_rate=traces_sample_rate, traces_sample_rate=traces_sample_rate,
send_default_pii=False, send_default_pii=False,
) )
log.info("sentry_initialized", service=service_name or settings.otel_service_name) log.info("sentry_initialized", service=service_name or "contract-check")

View file

@ -1,167 +1,117 @@
"""OpenTelemetry initialization. """Telemetry stubs after passive-collection refactor.
Sets up OTLP exporters for traces, logs, and metrics when The application no longer pushes OTLP traces, logs, or metrics. Logs are
`OTEL_EXPORTER_OTLP_ENDPOINT` is set; otherwise no-op. httpx auto-instrumentation written to stdout/stderr by ``core/logging.py`` and collected by the
is wired so Ollama Cloud calls appear as spans. Service entrypoints call container runtime or a sidecar (Vector in the ``observer`` profile,
`setup_telemetry()` early and `shutdown_telemetry()` on exit. Promtail in the ``obs`` profile). Prometheus metrics stay in-process and
are scraped from service ``/metrics`` endpoints.
Auth headers are taken from `OTEL_EXPORTER_OTLP_HEADERS` by the OTLP exporters ``setup_telemetry()`` and ``shutdown_telemetry()`` remain as safe no-op
(no code-level parsing needed). entrypoints so service ``__main__`` modules do not all have to change at
once. ``get_tracer()`` and ``get_meter()`` return lightweight no-op objects
This module imports `opentelemetry` only services in the `obs` group import it so any remaining instrumentation calls do not raise.
(see docs/ARCHITECTURE.md §4). mypy resolves it via the dev group.
""" """
from __future__ import annotations from __future__ import annotations
import logging from typing import Any
from typing import TYPE_CHECKING, Any
from src.contract_check.core.config import get_settings
from src.contract_check.core.logging import get_logger
if TYPE_CHECKING:
from opentelemetry.sdk._logs import LoggerProvider
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.trace import TracerProvider
log = get_logger(__name__)
_trace_provider: TracerProvider | None = None
_log_provider: LoggerProvider | None = None
_meter_provider: MeterProvider | None = None
def setup_telemetry(service_name: str | None = None) -> None: class _NoopSpan:
"""Initialize OTel tracing/logging/metrics. Safe to call when disabled.""" """Span-like object that ignores all operations."""
global _trace_provider, _log_provider, _meter_provider
settings = get_settings()
endpoint = settings.otel_exporter_otlp_endpoint
if not endpoint:
log.debug("otel_disabled", reason="no endpoint configured")
return
from opentelemetry._logs import set_logger_provider def end(self, _timestamp: Any = None) -> None:
from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter return None
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.metrics import set_meter_provider
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.trace import set_tracer_provider
class StructlogOTLPLogHandler(LoggingHandler): def set_attribute(self, _key: str, _value: Any) -> None:
"""OTEL handler that unwraps structlog event dicts. return None
``core/logging.py`` routes structlog through stdlib logging; the def record_exception(self, _exception: BaseException, _attributes: Any = None) -> None:
resulting ``LogRecord`` carries the event dictionary in return None
``record.msg``. This handler extracts the ``event`` as the log body and
promotes the remaining fields to OTEL attributes so OpenObserve can
index them.
"""
_STRUCTLOG_INTERNAL_KEYS = frozenset({"_logger", "_name"}) def __enter__(self) -> _NoopSpan:
return self
def _translate(self, record: logging.LogRecord) -> Any: def __exit__(self, *args: object) -> None:
# Detect structlog-wrapped records produced by return None
# ``ProcessorFormatter.wrap_for_formatter``.
if (
getattr(record, "_logger", None) is not None
and getattr(record, "_name", None) is not None
and isinstance(record.msg, (tuple, list))
and len(record.msg) == 1
and isinstance(record.msg[0], dict)
):
event_dict = record.msg[0]
# Work on a copy so the original record (used by stderr handler)
# is not mutated.
patched = logging.makeLogRecord(record.__dict__)
patched.msg = event_dict.get("event", "")
patched.args = ()
# Drop structlog internal bookkeeping copied from the original
# record so it does not leak into OTEL attributes.
for key in self._STRUCTLOG_INTERNAL_KEYS:
patched.__dict__.pop(key, None)
for key, value in event_dict.items():
if key in self._STRUCTLOG_INTERNAL_KEYS:
continue
if key not in patched.__dict__:
setattr(patched, key, value)
return super()._translate(patched)
return super()._translate(record)
resource = Resource.create({"service.name": service_name or settings.otel_service_name})
# Traces. Exporters read OTEL_EXPORTER_OTLP_ENDPOINT and append the class _NoopTracer:
# signal-specific path (/v1/traces, /v1/logs, /v1/metrics) automatically. """Tracer-like object that returns no-op spans."""
trace_provider = TracerProvider(resource=resource)
trace_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
set_tracer_provider(trace_provider)
_trace_provider = trace_provider
# Logs (stdlib logging → OTLP). ``configure_logging`` routes structlog def start_span(self, _name: str, _context: Any = None, _kind: Any = None) -> _NoopSpan:
# through the stdlib logging tree; this handler unwraps the structured return _NoopSpan()
# event dicts so OpenObserve receives the message body and fields.
log_provider = LoggerProvider(resource=resource)
log_provider.add_log_record_processor(BatchLogRecordProcessor(OTLPLogExporter()))
set_logger_provider(log_provider)
_log_provider = log_provider
otel_log_handler = StructlogOTLPLogHandler(logger_provider=log_provider) def start_as_current_span(self, _name: str, *_args: Any, **_kwargs: Any) -> _NoopSpan:
otel_log_handler.setLevel(getattr(logging, settings.log_level.upper(), logging.INFO)) return _NoopSpan()
logging.getLogger().addHandler(otel_log_handler)
# Metrics infrastructure (OTel metrics → OTLP). Existing prometheus_client
# metrics are still scraped by Prometheus; use remote_write for those.
metric_reader = PeriodicExportingMetricReader(OTLPMetricExporter())
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
set_meter_provider(meter_provider)
_meter_provider = meter_provider
try: class _NoopCounter:
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor """Counter-like object that ignores add() calls."""
HTTPXClientInstrumentor().instrument() def add(self, _amount: int | float, _attributes: Any = None) -> None:
except Exception as exc: # pragma: no cover - optional instrumentor return None
log.warning("otel_httpx_instrument_failed", error=str(exc))
log.info("otel_initialized", endpoint=endpoint)
# Stdlib log so the OTel handler exports a verification record too. class _NoopHistogram:
logging.getLogger("contract_check.telemetry").info( """Histogram-like object that ignores record() calls."""
"OpenTelemetry initialized for %s at %s",
service_name or settings.otel_service_name, def record(self, _amount: int | float, _attributes: Any = None) -> None:
endpoint, return None
)
class _NoopObservableGauge:
"""Observable gauge-like object that ignores callbacks."""
def __init__(self) -> None:
pass
class _NoopMeter:
"""Meter-like object that returns no-op instruments."""
def create_counter(self, _name: str, *_args: Any, **_kwargs: Any) -> _NoopCounter:
return _NoopCounter()
def create_histogram(self, _name: str, *_args: Any, **_kwargs: Any) -> _NoopHistogram:
return _NoopHistogram()
def create_up_down_counter(self, _name: str, *_args: Any, **_kwargs: Any) -> _NoopCounter:
return _NoopCounter()
def create_observable_gauge(
self, _name: str, *_args: Any, **_kwargs: Any
) -> _NoopObservableGauge:
return _NoopObservableGauge()
_TRACER = _NoopTracer()
_METER = _NoopMeter()
def setup_telemetry(_service_name: str | None = None) -> None:
"""No-op telemetry initialization retained for backward compatibility."""
return None
def shutdown_telemetry() -> None: def shutdown_telemetry() -> None:
"""Flush and shut down all OTel providers if initialized.""" """No-op telemetry shutdown retained for backward compatibility."""
global _trace_provider, _log_provider, _meter_provider return None
for provider in (_trace_provider, _log_provider, _meter_provider):
if provider is not None:
try:
provider.shutdown()
except Exception as exc: # pragma: no cover - shutdown must not raise
log.warning("otel_shutdown_failed", error=str(exc))
_trace_provider = None
_log_provider = None
_meter_provider = None
def get_tracer(name: str | None = None) -> Any: def get_tracer(_name: str | None = None) -> _NoopTracer:
"""Return a tracer. Returns a no-op-safe tracer when OTel is absent.""" """Return a no-op tracer.
from opentelemetry import trace
return trace.get_tracer(name or "contract_check") The application no longer uses OpenTelemetry; any existing calls to
``start_span`` or ``start_as_current_span`` are silently ignored.
"""
return _TRACER
def get_meter(name: str | None = None) -> Any: def get_meter(_name: str | None = None) -> _NoopMeter:
"""Return a meter. Returns a no-op-safe meter when OTel is absent.""" """Return a no-op meter.
from opentelemetry import metrics
return metrics.get_meter(name or "contract_check") The application no longer uses OpenTelemetry; any existing instrument
calls are silently ignored.
"""
return _METER

View file

@ -9,7 +9,6 @@ from src.contract_check.core.config import get_settings
from src.contract_check.core.logging import bind_context, configure_logging, get_logger from src.contract_check.core.logging import bind_context, configure_logging, get_logger
from src.contract_check.core.metrics import start_metrics_server from src.contract_check.core.metrics import start_metrics_server
from src.contract_check.core.sentry import init_sentry from src.contract_check.core.sentry import init_sentry
from src.contract_check.core.telemetry import setup_telemetry, shutdown_telemetry
from src.contract_check.worker_analyze.consumer import AnalyzeConsumer from src.contract_check.worker_analyze.consumer import AnalyzeConsumer
log = get_logger(__name__) log = get_logger(__name__)
@ -25,7 +24,6 @@ async def main() -> None:
) )
bind_context(service="worker-analyze", env=settings.env) bind_context(service="worker-analyze", env=settings.env)
init_sentry("worker-analyze") init_sentry("worker-analyze")
setup_telemetry("worker-analyze")
start_metrics_server(9102) start_metrics_server(9102)
@ -55,7 +53,6 @@ async def main() -> None:
) )
finally: finally:
await consumer.stop() await consumer.stop()
shutdown_telemetry()
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -12,7 +12,6 @@ from src.contract_check.core.db.session import create_session_factory, dispose_e
from src.contract_check.core.logging import bind_context, configure_logging, get_logger from src.contract_check.core.logging import bind_context, configure_logging, get_logger
from src.contract_check.core.metrics import start_metrics_server from src.contract_check.core.metrics import start_metrics_server
from src.contract_check.core.sentry import init_sentry from src.contract_check.core.sentry import init_sentry
from src.contract_check.core.telemetry import setup_telemetry, shutdown_telemetry
from src.contract_check.worker_billing.scheduler import run_tick from src.contract_check.worker_billing.scheduler import run_tick
log = get_logger(__name__) log = get_logger(__name__)
@ -30,7 +29,6 @@ async def main() -> None:
) )
bind_context(service="worker-billing", env=settings.env) bind_context(service="worker-billing", env=settings.env)
init_sentry("worker-billing") init_sentry("worker-billing")
setup_telemetry("worker-billing")
start_metrics_server(9105) start_metrics_server(9105)
@ -64,7 +62,6 @@ async def main() -> None:
if provider is not None: if provider is not None:
await provider.aclose() await provider.aclose()
await dispose_engine() await dispose_engine()
shutdown_telemetry()
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -9,7 +9,6 @@ from src.contract_check.core.config import get_settings
from src.contract_check.core.logging import bind_context, configure_logging, get_logger from src.contract_check.core.logging import bind_context, configure_logging, get_logger
from src.contract_check.core.metrics import start_metrics_server from src.contract_check.core.metrics import start_metrics_server
from src.contract_check.core.sentry import init_sentry from src.contract_check.core.sentry import init_sentry
from src.contract_check.core.telemetry import setup_telemetry, shutdown_telemetry
from src.contract_check.worker_extract.consumer import ExtractConsumer from src.contract_check.worker_extract.consumer import ExtractConsumer
log = get_logger(__name__) log = get_logger(__name__)
@ -25,7 +24,6 @@ async def main() -> None:
) )
bind_context(service="worker-extract", env=settings.env) bind_context(service="worker-extract", env=settings.env)
init_sentry("worker-extract") init_sentry("worker-extract")
setup_telemetry("worker-extract")
start_metrics_server(9101) start_metrics_server(9101)
@ -55,7 +53,6 @@ async def main() -> None:
) )
finally: finally:
await consumer.stop() await consumer.stop()
shutdown_telemetry()
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -9,7 +9,6 @@ from src.contract_check.core.config import get_settings
from src.contract_check.core.logging import bind_context, configure_logging, get_logger from src.contract_check.core.logging import bind_context, configure_logging, get_logger
from src.contract_check.core.metrics import start_metrics_server from src.contract_check.core.metrics import start_metrics_server
from src.contract_check.core.sentry import init_sentry from src.contract_check.core.sentry import init_sentry
from src.contract_check.core.telemetry import setup_telemetry, shutdown_telemetry
from src.contract_check.worker_notify.consumer import NotifyConsumer from src.contract_check.worker_notify.consumer import NotifyConsumer
log = get_logger(__name__) log = get_logger(__name__)
@ -25,7 +24,6 @@ async def main() -> None:
) )
bind_context(service="worker-notify", env=settings.env) bind_context(service="worker-notify", env=settings.env)
init_sentry("worker-notify") init_sentry("worker-notify")
setup_telemetry("worker-notify")
start_metrics_server(9103) start_metrics_server(9103)
@ -55,7 +53,6 @@ async def main() -> None:
) )
finally: finally:
await consumer.stop() await consumer.stop()
shutdown_telemetry()
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -9,7 +9,6 @@ from src.contract_check.core.config import get_settings
from src.contract_check.core.logging import bind_context, configure_logging, get_logger from src.contract_check.core.logging import bind_context, configure_logging, get_logger
from src.contract_check.core.metrics import start_metrics_server from src.contract_check.core.metrics import start_metrics_server
from src.contract_check.core.sentry import init_sentry from src.contract_check.core.sentry import init_sentry
from src.contract_check.core.telemetry import setup_telemetry, shutdown_telemetry
from src.contract_check.worker_prescreen.consumer import PrescreenConsumer from src.contract_check.worker_prescreen.consumer import PrescreenConsumer
log = get_logger(__name__) log = get_logger(__name__)
@ -25,7 +24,6 @@ async def main() -> None:
) )
bind_context(service="worker-prescreen", env=settings.env) bind_context(service="worker-prescreen", env=settings.env)
init_sentry("worker-prescreen") init_sentry("worker-prescreen")
setup_telemetry("worker-prescreen")
start_metrics_server(9104) start_metrics_server(9104)
@ -55,7 +53,6 @@ async def main() -> None:
) )
finally: finally:
await consumer.stop() await consumer.stop()
shutdown_telemetry()
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -28,15 +28,11 @@ from src.contract_check.worker_prescreen.extractor import (
from src.contract_check.worker_prescreen.extractor_heuristic import ( from src.contract_check.worker_prescreen.extractor_heuristic import (
EXTRACTOR_VERSION as HEURISTIC_VERSION, EXTRACTOR_VERSION as HEURISTIC_VERSION,
) )
from src.contract_check.worker_prescreen.extractor_heuristic import ( from src.contract_check.worker_prescreen.extractor_heuristic import HeuristicExtractor
HeuristicExtractor,
)
from src.contract_check.worker_prescreen.extractor_llm import ( from src.contract_check.worker_prescreen.extractor_llm import (
EXTRACTOR_VERSION as HYBRID_LLM_VERSION, EXTRACTOR_VERSION as HYBRID_LLM_VERSION,
) )
from src.contract_check.worker_prescreen.extractor_llm import ( from src.contract_check.worker_prescreen.extractor_llm import LLMPrescreenExtractor
LLMPrescreenExtractor,
)
log = get_logger(__name__) log = get_logger(__name__)

View file

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# ─── Stage 1: build deps + project into a venv via uv ───────────────────────── # ─── Stage 1: build deps + project into a venv via uv ─────────────────────────
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder
ENV UV_COMPILE_BYTECODE=1 \ ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \ UV_LINK_MODE=copy \
@ -20,7 +20,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-default-groups --group api --no-install-project uv sync --frozen --no-default-groups --group api --no-install-project
# ─── Stage 2: lean runtime ───────────────────────────────────────────────── # ─── Stage 2: lean runtime ─────────────────────────────────────────────────
FROM python:3.13-slim FROM python:3.14-slim-trixie
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \

View file

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# ─── Stage 1: build deps + project into a venv via uv ───────────────────────── # ─── Stage 1: build deps + project into a venv via uv ─────────────────────────
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder
ENV UV_COMPILE_BYTECODE=1 \ ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \ UV_LINK_MODE=copy \
@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-default-groups --group bot --no-install-project uv sync --frozen --no-default-groups --group bot --no-install-project
# ─── Stage 2: lean runtime ───────────────────────────────────────────────── # ─── Stage 2: lean runtime ─────────────────────────────────────────────────
FROM python:3.13-slim FROM python:3.14-slim-trixie
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \

View file

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# ─── Stage 1: build deps + project into a venv via uv ───────────────────────── # ─── Stage 1: build deps + project into a venv via uv ─────────────────────────
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder
ENV UV_COMPILE_BYTECODE=1 \ ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \ UV_LINK_MODE=copy \
@ -13,13 +13,13 @@ WORKDIR /app
COPY pyproject.toml uv.lock ./ COPY pyproject.toml uv.lock ./
COPY README.md ./ COPY README.md ./
# Install only the analyze group (core + db/mq/s3/obs + otel-httpx). No # Install only the analyze group (core + db/mq/s3/obs). No tesseract/pymupdf
# tesseract/pymupdf — this image is the leanest LLM/I/O worker. # — this image is the leanest LLM/I/O worker.
RUN --mount=type=cache,target=/root/.cache/uv \ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-default-groups --group analyze --no-install-project uv sync --frozen --no-default-groups --group analyze --no-install-project
# ─── Stage 2: lean runtime ───────────────────────────────────────────────── # ─── Stage 2: lean runtime ─────────────────────────────────────────────────
FROM python:3.13-slim FROM python:3.14-slim-trixie
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \

View file

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# ─── Stage 1: build deps + project into a venv via uv ───────────────────────── # ─── Stage 1: build deps + project into a venv via uv ─────────────────────────
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder
ENV UV_COMPILE_BYTECODE=1 \ ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \ UV_LINK_MODE=copy \
@ -17,7 +17,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-default-groups --group billing --no-install-project uv sync --frozen --no-default-groups --group billing --no-install-project
# ─── Stage 2: lean runtime ───────────────────────────────────────────────── # ─── Stage 2: lean runtime ─────────────────────────────────────────────────
FROM python:3.13-slim FROM python:3.14-slim-trixie
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \

View file

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# ─── Stage 1: build deps + project into a venv via uv ───────────────────────── # ─── Stage 1: build deps + project into a venv via uv ─────────────────────────
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder
ENV UV_COMPILE_BYTECODE=1 \ ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \ UV_LINK_MODE=copy \
@ -18,7 +18,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-default-groups --group extract --no-install-project uv sync --frozen --no-default-groups --group extract --no-install-project
# ─── Stage 2: runtime with tesseract-ocr + language packs ──────────────────── # ─── Stage 2: runtime with tesseract-ocr + language packs ────────────────────
FROM python:3.13-slim FROM python:3.14-slim-trixie
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
@ -33,7 +33,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
tesseract-ocr-rus \ tesseract-ocr-rus \
tesseract-ocr-eng \ tesseract-ocr-eng \
fonts-dejavu-core \ fonts-dejavu-core \
libmagic1 \ libmagic1t64 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/.venv /app/.venv COPY --from=builder /app/.venv /app/.venv

View file

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# ─── Stage 1: build deps + project into a venv via uv ───────────────────────── # ─── Stage 1: build deps + project into a venv via uv ─────────────────────────
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder
ENV UV_COMPILE_BYTECODE=1 \ ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \ UV_LINK_MODE=copy \
@ -18,7 +18,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-default-groups --group notify --no-install-project uv sync --frozen --no-default-groups --group notify --no-install-project
# ─── Stage 2: lean runtime ───────────────────────────────────────────────── # ─── Stage 2: lean runtime ─────────────────────────────────────────────────
FROM python:3.13-slim FROM python:3.14-slim-trixie
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \

View file

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# ─── Stage 1: build deps + project into a venv via uv ───────────────────────── # ─── Stage 1: build deps + project into a venv via uv ─────────────────────────
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder
ENV UV_COMPILE_BYTECODE=1 \ ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \ UV_LINK_MODE=copy \
@ -18,7 +18,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-default-groups --group prescreen --no-install-project uv sync --frozen --no-default-groups --group prescreen --no-install-project
# ─── Stage 2: lean runtime ───────────────────────────────────────────────── # ─── Stage 2: lean runtime ─────────────────────────────────────────────────
FROM python:3.13-slim FROM python:3.14-slim-trixie
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \

View file

@ -1,15 +1,21 @@
"""Integration-test fixtures using the running Docker Compose infrastructure. """Integration-test fixtures using an isolated Docker Compose infrastructure.
Run `docker compose up -d` before executing integration tests. Migrations run The test infra (postgres + redis + rabbitmq + minio) is brought up automatically
once per session; a service token is seeded so adapter-style endpoints can by the session-scoped `infra` fixture and torn down after the session. This
authenticate. keeps integration tests from racing against the development service workers
that also consume RabbitMQ queues.
To run against an already-running external infra instead, set
``INTEGRATION_TEST_USE_EXTERNAL_INFRA=1`` before invoking pytest.
""" """
from __future__ import annotations from __future__ import annotations
import json
import os import os
import subprocess import subprocess
import sys import sys
import time
from collections.abc import Iterator from collections.abc import Iterator
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -32,9 +38,113 @@ pytestmark = pytest.mark.integration
_BOT_SERVICE_TOKEN = "it-test-bot-token" _BOT_SERVICE_TOKEN = "it-test-bot-token"
_DB_URL = "postgresql+asyncpg://contract_check:contract_check@localhost:15432/contract_check" # Ports must match docker-compose.test.yml.
_AMQP_URL = "amqp://contract_check:contract_check@localhost:5672//" _TEST_DB_URL = "postgresql+asyncpg://contract_check:contract_check@localhost:25432/contract_check"
_S3_URL = "http://localhost:9000" _TEST_AMQP_URL = "amqp://contract_check:contract_check@localhost:6672//"
_TEST_S3_URL = "http://localhost:10000"
_DB_URL = _TEST_DB_URL
_AMQP_URL = _TEST_AMQP_URL
_S3_URL = _TEST_S3_URL
_USE_EXTERNAL_INFRA = os.environ.get("INTEGRATION_TEST_USE_EXTERNAL_INFRA", "0") == "1"
def _repo_root() -> Path:
return Path(__file__).resolve().parents[2]
def _run(cmd: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
cmd,
cwd=str(_repo_root()),
env={**os.environ},
check=False,
capture_output=True,
text=True,
)
if check and result.returncode != 0:
raise subprocess.CalledProcessError(
result.returncode,
cmd,
output=result.stdout,
stderr=result.stderr,
)
return result
def _docker_compose_test(args: list[str]) -> list[str]:
# Use a dedicated project name so the test stack is independent from the
# development stack. Without this, `docker compose` treats the running dev
# services as "orphans" and returns a non-zero exit code.
return [
"docker",
"compose",
"-p",
"contract-check-test",
"-f",
str(_repo_root() / "docker-compose.test.yml"),
*args,
]
def _wait_for_healthy(service: str, *, deadline_seconds: int = 60) -> None:
"""Poll `docker compose ps` until `service` reports healthy."""
start = time.monotonic()
while time.monotonic() - start < deadline_seconds:
result = _run(
_docker_compose_test(["ps", service, "--format", "json"]),
check=False,
)
if result.returncode == 0 and result.stdout:
# `docker compose ps --format json` emits one JSON object per line.
for line in result.stdout.strip().splitlines():
try:
info = json.loads(line)
except json.JSONDecodeError:
continue
if info.get("Health") == "healthy":
return
if info.get("State") == "exited" and info.get("ExitCode") == 0:
# One-shot containers are also fine.
return
time.sleep(1)
raise RuntimeError(f"service {service} did not become healthy within {deadline_seconds}s")
def _start_test_infra() -> None:
if _USE_EXTERNAL_INFRA:
return
# Bring up the test stack without --wait: minio-init-test is a one-shot
# container that exits after creating the bucket, which makes --wait fail.
_run(_docker_compose_test(["up", "-d"]))
# Wait for every persistent service to be healthy.
for service in ("postgres-test", "redis-test", "rabbitmq-test", "minio-test"):
_wait_for_healthy(service)
# postgres healthcheck can still race with alembic, so explicitly wait for
# the DB to accept connections.
_run(
_docker_compose_test(
[
"exec",
"-T",
"postgres-test",
"sh",
"-c",
"until pg_isready -U contract_check -d contract_check; do sleep 1; done",
]
)
)
def _stop_test_infra() -> None:
if _USE_EXTERNAL_INFRA:
return
# Use --volumes to wipe test data between runs. Never use -v for external infra.
_run(_docker_compose_test(["down", "-v"]), check=False)
def _set_env() -> None: def _set_env() -> None:
@ -44,8 +154,8 @@ def _set_env() -> None:
"S3_ENDPOINT_URL": _S3_URL, "S3_ENDPOINT_URL": _S3_URL,
"S3_ACCESS_KEY": "contract_check", "S3_ACCESS_KEY": "contract_check",
"S3_SECRET_KEY": "contract_check", "S3_SECRET_KEY": "contract_check",
"S3_BUCKET": "contract-check-docs", "S3_BUCKET": "contract-check-docs-test",
"REDIS_URL": "redis://localhost:17379/0", "REDIS_URL": "redis://localhost:27379/0",
"OLLAMA_HOST": "http://localhost", "OLLAMA_HOST": "http://localhost",
"OLLAMA_API_KEY": "test", "OLLAMA_API_KEY": "test",
"JWT_SECRET": "it-test-jwt-secret-not-for-production", "JWT_SECRET": "it-test-jwt-secret-not-for-production",
@ -57,6 +167,11 @@ def _set_env() -> None:
# `timeout` event). Keep it short so streaming tests finish fast. # `timeout` event). Keep it short so streaming tests finish fast.
"SSE_POLL_INTERVAL_SECONDS": "0.25", "SSE_POLL_INTERVAL_SECONDS": "0.25",
"SSE_MAX_STREAM_SECONDS": "5", "SSE_MAX_STREAM_SECONDS": "5",
# CI has no .env, so the Settings default (true) would route
# ExtractHandler to prescreen.q instead of analyze.q. The prescreen
# stage is exercised directly in test_prescreen_worker.py; the
# extract-handler tests assert the analyze.q path.
"PRESCREEN_ENABLED": "false",
} }
for k, v in env_vars.items(): for k, v in env_vars.items():
os.environ[k] = v os.environ[k] = v
@ -67,13 +182,19 @@ def infra() -> Iterator[dict[str, str]]:
_set_env() _set_env()
get_settings.cache_clear() get_settings.cache_clear()
repo_root = Path(__file__).resolve().parents[2] _start_test_infra()
subprocess.run(
[sys.executable, "-m", "alembic", "-c", str(repo_root / "alembic.ini"), "upgrade", "head"], _run(
cwd=str(repo_root), [
env={**os.environ}, sys.executable,
"-m",
"alembic",
"-c",
str(_repo_root() / "alembic.ini"),
"upgrade",
"head",
],
check=True, check=True,
capture_output=False,
) )
factory = create_session_factory() factory = create_session_factory()
@ -101,10 +222,12 @@ def infra() -> Iterator[dict[str, str]]:
"s3_endpoint_url": _S3_URL, "s3_endpoint_url": _S3_URL,
"s3_access_key": "contract_check", "s3_access_key": "contract_check",
"s3_secret_key": "contract_check", "s3_secret_key": "contract_check",
"s3_bucket": "contract-check-docs-test",
"token": _BOT_SERVICE_TOKEN, "token": _BOT_SERVICE_TOKEN,
} }
get_settings.cache_clear() get_settings.cache_clear()
_stop_test_infra()
@pytest_asyncio.fixture @pytest_asyncio.fixture

View file

@ -35,7 +35,7 @@ def _storage(infra: dict[str, str]) -> MinioStorage:
endpoint_url=infra["s3_endpoint_url"], endpoint_url=infra["s3_endpoint_url"],
access_key=infra["s3_access_key"], access_key=infra["s3_access_key"],
secret_key=infra["s3_secret_key"], secret_key=infra["s3_secret_key"],
bucket="contract-check-docs", bucket=infra["s3_bucket"],
) )

View file

@ -63,7 +63,7 @@ def _storage(infra: dict[str, str]) -> MinioStorage:
endpoint_url=infra["s3_endpoint_url"], endpoint_url=infra["s3_endpoint_url"],
access_key=infra["s3_access_key"], access_key=infra["s3_access_key"],
secret_key=infra["s3_secret_key"], secret_key=infra["s3_secret_key"],
bucket="contract-check-docs", bucket=infra["s3_bucket"],
) )

View file

@ -8,7 +8,7 @@ from __future__ import annotations
import hashlib import hashlib
import hmac import hmac
import time import time
from urllib.parse import urlencode from urllib.parse import urlencode, urlsplit, urlunsplit
import httpx import httpx
import pytest import pytest
@ -181,7 +181,12 @@ async def _strict_rate_limit_client() -> httpx.AsyncClient:
from contract_check.api.app import create_app from contract_check.api.app import create_app
from contract_check.core.config import get_settings from contract_check.core.config import get_settings
isolated_redis_url = "redis://localhost:17379/15" # Reuse the infra Redis (set by conftest `_set_env`, test-stack port);
# fall back to the dev-stack port for standalone local runs. DB 15 is
# isolated so concurrent/sequential tests do not share buckets.
base_url = os.environ.get("REDIS_URL", "redis://localhost:27379/0")
parts = urlsplit(base_url)
isolated_redis_url = urlunsplit((parts.scheme, parts.netloc, "/15", "", ""))
r = redis.from_url(isolated_redis_url) r = redis.from_url(isolated_redis_url)
await r.flushdb() await r.flushdb()
await r.aclose() await r.aclose()

View file

@ -39,7 +39,7 @@ def storage(infra: dict[str, str]) -> MinioStorage:
endpoint_url=infra["s3_endpoint_url"], endpoint_url=infra["s3_endpoint_url"],
access_key=infra["s3_access_key"], access_key=infra["s3_access_key"],
secret_key=infra["s3_secret_key"], secret_key=infra["s3_secret_key"],
bucket="contract-check-docs", bucket=infra["s3_bucket"],
) )

View file

@ -100,7 +100,7 @@ def _storage(infra: dict[str, str]) -> MinioStorage:
endpoint_url=infra["s3_endpoint_url"], endpoint_url=infra["s3_endpoint_url"],
access_key=infra["s3_access_key"], access_key=infra["s3_access_key"],
secret_key=infra["s3_secret_key"], secret_key=infra["s3_secret_key"],
bucket="contract-check-docs", bucket=infra["s3_bucket"],
) )

View file

@ -83,7 +83,7 @@ def _storage(infra: dict[str, str]) -> MinioStorage:
endpoint_url=infra["s3_endpoint_url"], endpoint_url=infra["s3_endpoint_url"],
access_key=infra["s3_access_key"], access_key=infra["s3_access_key"],
secret_key=infra["s3_secret_key"], secret_key=infra["s3_secret_key"],
bucket="contract-check-docs", bucket=infra["s3_bucket"],
) )

View file

@ -0,0 +1,50 @@
"""Stage/status label mapping for Telegram bot UI.
Guards the regression where backend statuses like `prescreening` and stages
such as `extracting_meta` were not present in `_STAGE_LABELS`, so the bot
showed the generic "Обрабатываю…" for every document in the `/reports` list
and for `/status` replies.
"""
from __future__ import annotations
import pytest
from contract_check.bot.client import ReportStatus
from contract_check.bot.handlers import _stage_label, _stage_label_from
@pytest.mark.parametrize(
"status,stage,expected",
[
("queued", None, "Документ принят. В очереди на обработку…"),
("extracting", None, "Извлекаю текст из документа…"),
("extracting", "ocr", "Документ похож на скан — распознаю страницы (OCR)…"),
("prescreening", "extracting_meta", "Извлекаю реквизиты договора…"),
("prescreening", None, "Предварительная проверка договора…"),
("analyzing", "queued_analyze", "Подготовка к глубокому анализу…"),
("analyzing", None, "Анализирую риски по чек-листу…"),
("manual_review", "manual_review", "Отправлен на ручную проверку"),
("done", "auto_approved", "Готово (автопроверка)"),
("done", None, "Готово"),
("failed", "analyze", "Ошибка обработки"),
("unknown_future_status", None, "Обрабатываю…"),
("unknown", "unknown_stage", "Обрабатываю…"),
],
)
def test_stage_label_mapping(status: str, stage: str | None, expected: str) -> None:
report = ReportStatus(
document_id="d",
status=status,
stage=stage,
markdown=None,
filename=None,
)
assert _stage_label(report) == expected
def test_stage_label_from_uses_status_fallback_when_stage_unknown() -> None:
# Stage is unknown, but status maps to a known label.
assert _stage_label_from("analyzing", "surprise_stage") == "Анализирую риски по чек-листу…"
# Both stage and status are unknown.
assert _stage_label_from("surprise", "surprise") == "Обрабатываю…"

View file

@ -145,3 +145,37 @@ async def test_event_hooks_silent_without_debug(monkeypatch: pytest.MonkeyPatch)
await client.get("https://api.test/v1/things") await client.get("https://api.test/v1/things")
assert logs == [] assert logs == []
async def test_event_hooks_handle_streaming_multipart_body(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Multipart uploads stream their body; the request hook must not crash."""
monkeypatch.setattr(hhttp, "is_debug_enabled", lambda: True)
_config = structlog.get_config()
monkeypatch.setattr(
hhttp,
"get_logger",
lambda name: structlog.get_logger(name),
)
structlog.configure(
processors=_config["processors"],
wrapper_class=structlog.stdlib.BoundLogger,
logger_factory=_config["logger_factory"],
cache_logger_on_first_use=False,
)
with capture_logs() as logs:
transport = httpx.MockTransport(lambda request: httpx.Response(202, json={"ok": True}))
async with httpx.AsyncClient(
transport=transport,
event_hooks=http_log_event_hooks(service="test-svc"),
) as client:
response = await client.post(
"https://api.test/v1/documents",
files={"file": ("contract.pdf", b"%PDF-1.7 fake", "application/pdf")},
)
assert response.status_code == 202
request_entry = next(entry for entry in logs if entry.get("event") == "http_request")
assert request_entry["method"] == "POST"
assert request_entry["body"] == "<streaming body not read>"

View file

@ -1,7 +1,8 @@
"""Unit tests for OpenTelemetry integration. """Unit tests for telemetry after the passive-collection refactor.
Covers the structlog-aware OTEL log handler that unwraps event dicts so The application no longer pushes OTLP. This module verifies that telemetry
OpenObserve receives structured attributes. setup/shutdown are safe no-ops and that structured logging still reaches the
configured root handler.
""" """
from __future__ import annotations from __future__ import annotations
@ -11,6 +12,7 @@ import logging
import pytest import pytest
from contract_check.core.logging import configure_logging, get_logger from contract_check.core.logging import configure_logging, get_logger
from contract_check.core.telemetry import get_meter, get_tracer, setup_telemetry, shutdown_telemetry
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@ -22,82 +24,64 @@ def _reset_logging():
root.handlers.clear() root.handlers.clear()
def test_structlog_otlp_handler_unwraps_event_dict(monkeypatch: pytest.MonkeyPatch) -> None: def test_setup_telemetry_is_safe_noop() -> None:
"""A structlog-wrapped record is converted to body + attributes.""" """setup_telemetry() runs without error and does not add an OTLP handler."""
from contract_check.core.config import get_settings
from contract_check.core.telemetry import setup_telemetry, shutdown_telemetry
monkeypatch.setattr(
get_settings(), "otel_exporter_otlp_endpoint", "http://localhost:9999/v1/logs"
)
# setup_telemetry defines the handler class when OTEL is imported.
setup_telemetry("test")
root = logging.getLogger() root = logging.getLogger()
before = list(root.handlers)
setup_telemetry("test")
try: try:
otel_handler = next( after = list(root.handlers)
h for h in root.handlers if type(h).__name__ == "StructlogOTLPLogHandler" # No new handlers are attached because OTLP is no longer initialized.
) assert after == before
record = logging.LogRecord(
name="test.logger",
level=logging.INFO,
pathname="test.py",
lineno=5,
msg=({"event": "hello_openobserve", "foo": "bar", "count": 42},),
args=(),
exc_info=None,
)
record._logger = logging.getLogger("test.logger") # type: ignore[attr-defined]
record._name = "info" # type: ignore[attr-defined]
otel_record = otel_handler._translate(record)
assert otel_record.body == "hello_openobserve"
attrs = {k: v for k, v in otel_record.attributes.items() if not k.startswith("code.")}
assert attrs["foo"] == "bar"
assert attrs["count"] == 42
assert "_logger" not in attrs
assert "_name" not in attrs
finally: finally:
shutdown_telemetry() shutdown_telemetry()
root.handlers = [h for h in root.handlers if type(h).__name__ != "StructlogOTLPLogHandler"]
def test_structlog_logs_reach_root_handlers() -> None: def test_shutdown_telemetry_is_safe_noop() -> None:
"""After configure_logging, structlog events propagate to stdlib handlers.""" """shutdown_telemetry() can be called even when setup_telemetry() was skipped."""
shutdown_telemetry()
shutdown_telemetry() # idempotent
def test_otlp_endpoint_env_does_not_initialize_exporters(monkeypatch: pytest.MonkeyPatch) -> None:
"""A legacy OTLP endpoint env var must not cause the application to import OTel SDK."""
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector:4318")
from contract_check.core.config import get_settings
# Settings explicitly ignores unknown env vars, but a stale env var must not
# be picked up by telemetry code. Refresh the cached settings instance.
get_settings.cache_clear()
root = logging.getLogger() root = logging.getLogger()
assert ( before = list(root.handlers)
any(type(h).__name__ in ("StreamHandler", "ProcessorFormatter") for h in root.handlers) setup_telemetry("test")
or root.handlers try:
) after = list(root.handlers)
assert after == before
assert not any(type(h).__name__.startswith(("OTLP", "StructlogOTLP")) for h in after)
finally:
shutdown_telemetry()
get_settings.cache_clear()
def test_get_tracer_and_meter_are_safe_noop() -> None:
"""Tracer/meter accessors return objects that do not require OTel SDK."""
tracer = get_tracer("test")
meter = get_meter("test")
# They should be truthy and accept the expected creation calls without raising.
assert tracer
assert meter
span = tracer.start_span("ignored")
span.end()
counter = meter.create_counter("ignored")
counter.add(1)
def test_configure_logging_delivers_events_to_root_stream_handler() -> None:
"""After configure_logging, structlog events propagate to the root StreamHandler."""
root = logging.getLogger()
assert any(isinstance(h, logging.StreamHandler) for h in root.handlers)
log = get_logger("test") log = get_logger("test")
log.info("smoke_test", answer=42) log.info("smoke_test", answer=42)
@pytest.mark.parametrize("level", ["DEBUG", "INFO", "WARNING"])
def test_otel_handler_respects_configured_log_level(
monkeypatch: pytest.MonkeyPatch, level: str
) -> None:
"""The OTLP handler level matches settings.log_level so DEBUG logs can reach OpenObserve."""
from contract_check.core.config import get_settings
from contract_check.core.logging import configure_logging
from contract_check.core.telemetry import setup_telemetry, shutdown_telemetry
configure_logging(level, json_output=True, service="test-service", env="test")
monkeypatch.setattr(
get_settings(), "otel_exporter_otlp_endpoint", "http://localhost:9999/v1/logs"
)
monkeypatch.setattr(get_settings(), "log_level", level)
setup_telemetry("test")
root = logging.getLogger()
try:
otel_handler = next(
h for h in root.handlers if type(h).__name__ == "StructlogOTLPLogHandler"
)
assert otel_handler.level == getattr(logging, level)
finally:
shutdown_telemetry()
root.handlers = [h for h in root.handlers if type(h).__name__ != "StructlogOTLPLogHandler"]

View file

@ -74,8 +74,13 @@ class FakeProvider(PaymentProvider):
async def factory(): async def factory():
import os import os
os.environ["DATABASE_URL"] = ( # Integration conftest (_set_env) already points DATABASE_URL at the test
"postgresql+asyncpg://contract_check:contract_check@localhost:15432/contract_check" # infra (:25432); only fall back to the dev port when it is unset, and
# restore the previous value afterwards.
prev_url = os.environ.get("DATABASE_URL")
os.environ.setdefault(
"DATABASE_URL",
"postgresql+asyncpg://contract_check:contract_check@localhost:15432/contract_check",
) )
os.environ["PLANS_ENABLED"] = "true" os.environ["PLANS_ENABLED"] = "true"
os.environ["YOOKASSA_ENABLED"] = "true" os.environ["YOOKASSA_ENABLED"] = "true"
@ -85,7 +90,10 @@ async def factory():
get_settings.cache_clear() get_settings.cache_clear()
f = create_session_factory() f = create_session_factory()
yield f yield f
if prev_url is None:
os.environ.pop("DATABASE_URL", None) os.environ.pop("DATABASE_URL", None)
else:
os.environ["DATABASE_URL"] = prev_url
os.environ.pop("PLANS_ENABLED", None) os.environ.pop("PLANS_ENABLED", None)
os.environ.pop("YOOKASSA_ENABLED", None) os.environ.pop("YOOKASSA_ENABLED", None)
os.environ.pop("YOOKASSA_SHOP_ID", None) os.environ.pop("YOOKASSA_SHOP_ID", None)

1496
uv.lock generated

File diff suppressed because it is too large Load diff