Architecture diagram was created.
This commit is contained in:
parent
07b1994c32
commit
c4b22be773
4 changed files with 15696 additions and 0 deletions
|
|
@ -15,6 +15,7 @@ repos:
|
|||
exclude: ^deploy/vps/docker-compose\.override\.example\.yml$
|
||||
- id: check-toml
|
||||
- id: check-added-large-files
|
||||
exclude: ^docs/
|
||||
- id: check-merge-conflict
|
||||
- id: mixed-line-ending
|
||||
args: [--fix=lf]
|
||||
|
|
|
|||
363
docs/ARCHITECTURE_AS_BUILT.md
Normal file
363
docs/ARCHITECTURE_AS_BUILT.md
Normal 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^(attempt−1)` (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 0001–0011)
|
||||
|
||||
| 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/`.
|
||||
15272
docs/diagrams/contract-check-architecture.html
Normal file
15272
docs/diagrams/contract-check-architecture.html
Normal file
File diff suppressed because one or more lines are too long
60
docs/diagrams/contract-check-architecture.visual-check.json
Normal file
60
docs/diagrams/contract-check-architecture.visual-check.json
Normal 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."
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue