---
paths: backend/**/*.py
---

# Backend conventions — Python 3.12 / FastAPI

## psycopg v3 (CRITICAL — recurring bug class)

- `import psycopg2` → **ModuleNotFoundError** — его нет в зависимостях. Только `psycopg[binary]>=3.2.0`.
- Bulk INSERT: `cur.executemany()` или COPY — НЕ `execute_values` (это psycopg2 API).
- В SQL `text(...)`: всегда `CAST(:name AS type)` — НИКОГДА `:name::type`.
  - SQLAlchemy 2.0 + psycopg3 **игнорирует** `::type` после `:name` — `psycopg.errors.SyntaxError: syntax error at or near ':'`
  - Исключение: `ARRAY[:rc]::int[]` РАБОТАЕТ — `::` приклеено к `)`, не к bind-name
  - Pre-PR check: `grep -nE ':[a-z_]+::[a-z]' backend/app` → должен быть пуст
  - Reference: vault `Pattern_CAST_AS_Type`, `Bug_SQLAlchemy_DoubleColon_Cast`

## SAVEPOINT pattern (loop upserts)

В цикле INSERT/UPSERT — **обязательно** `with db.begin_nested():` per-row:

```python
for row in items:
    try:
        with db.begin_nested():
            db.execute(text("INSERT INTO ..."), {...})
    except Exception as e:
        logger.warning("row failed: %s", e)
```

❌ Bare `db.rollback()` в loop — откатывает **всю outer tx**, счётчики `inserted`/`updated` продолжают расти → inconsistent state.

Reference: vault `Bug_Pzz_Loader_Missing_Savepoint_May14`, `domrf_kn.py:427/452/472`.

## SQL injection prevention

❌ **Никогда**:
- f-string в SQL: `text(f"INSERT ... VALUES ({values})")`
- Home-rolled quote escape: `c.replace(chr(39), chr(39)*2)`
- String concat: `"... WHERE id = " + str(id)`

✅ **Canonical**: parametrized batch через SQLAlchemy `text` + list of dicts:

```python
db.execute(
    text("INSERT INTO targets (job_id, cad_num) VALUES (:job_id, :cad_num) "
         "ON CONFLICT (job_id, cad_num) DO NOTHING"),
    [{"job_id": j, "cad_num": c} for c in cad_nums],
)
```

Reference: vault `Bug_Nspd_Geo_Sql_Injection_May14`.

## Ruff / code style

- **Line length 100** (`backend/pyproject.toml`)
- Типичные ловушки E501:
  - SQL-литералы в `text("""...""")` — переноси после `FROM` / `WHERE` / `AND`
  - `logger.info("...", a, b, c, d)` — разбивай на 2 строки
  - Длинная сигнатура — аргументы по одному
- Ruff rules: `E F I B UP N RUF ASYNC` (RUF001/002/003 игнор — кириллица OK)
- mypy `strict`: `app.services.generative.*` + `app.services.site_finder.scorer`
- target-version `py312` — `dict | None`, `list[int]`, walrus OK

## Async / Celery

- FastAPI handlers: `async def`
- Celery tasks: `def` (Celery sync) — НЕ `async def` внутри task
- Sync↔async bridge через `asyncio.run()` внутри Celery task
- Tests: pytest + `asyncio_mode = auto`

## Запреты

- ❌ `print()` — только `logger.info/warning/error`
- ❌ `import requests` — только `httpx`
- ❌ `except Exception: pass` без re-raise или `logger.exception`
- ❌ Hardcode credentials — `os.environ.get("KEY")` или `settings.KEY`
- ❌ `Any` без комментария почему

## Web probing (scrapers — use playwright MCP, not curl)

При исследовании веба (как страница реагирует на параметры/фильтры, что меняется в DOM, какие селекторы возвращают данные, anti-bot behavior) — **сначала** `mcp__playwright__*`, а не curl/curl_cffi.

**Why:** Реальный браузер видит финальный URL после canonicalization + JS state без anti-bot ловушек. Curl попадает в captcha на 2-3-й итерации. User rule (2026-05-23): «если что-то не понятно лучше через плэйврайт смотри чем через консоль».

**How to apply:**
- Discover URL params / filter behavior → `browser_navigate` + `browser_snapshot`
- Verify selectors / count cards → `browser_evaluate` с JS возвращающим counts
- Inspect network → `browser_network_requests`
- Captcha detection → `browser_snapshot` + check для challenge elements

**Curl/curl_cffi оставляем** для actual scraping в production-скриптах **после** того как param set уже известен (playwright медленнее + heavier для bulk fetch).

Reference incident: 2026-05-23 reverse-engineering Yandex SERP filter params через curl — captcha на 2-й итерации, recovery через cookie-warm session. С playwright сразу видели бы JS state.

## Worker crash checklist

Worker падает на старте → скорее всего import error: `pyproject.toml` имеет dep но `uv.lock` не обновлён → `cd backend && uv lock` → commit lock → push.
