Compare commits

...

16 Commits

Author SHA1 Message Date
Дмитрий 9d4a30c314 docs(pilot): snapshot 25.05.2026 (день+1) — saas-admin nginx-gate + drift-fix на проде
Два commits на main выкачены на боевой liderra.ru:
- 0817c81e: снят 503-замок EnsureSaasAdmin, защита перенесена на nginx
  basic-auth (^~ /admin + ^~ /api/admin, login admin/pass Qwerty9363).
  Закрывает класс «вся админка 503 на проде» (ждала Б-1+DO-4 SSO).
- 3eb6c7fe: schema v8.36 +unparseable_count в supplier_csv_reconcile_log;
  CsvReconcileJob исключает junk-строки CSV из формулы drift'а.
  Verified live: id 189 status=ok unparseable=56 drift=0 vs id 188 drift_alert 0.448.

Open issue: EnsureSaasAdmin.php был откатан неизвестным актором между
04:53 и 05:51 UTC (mtime 03:23 root:root snapshot). Cron/deploy-script
не найдены. Re-deploy 05:56 устойчив. Мониторить.

+7 слов в cspell-words.txt (стопгэп/досылает/creds/опкэш/гэп/misowned/деплоями).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:05:14 +03:00
Дмитрий 3eb6c7fecd fix(supplier): убрать false-positive drift_alert от мусора в CSV (Спек A)
CsvReconcileJob каждый час стабильно ставил drift_alert ~40-50% (10 запусков
подряд на проде → admin-блок «Здоровье резервного канала» показывал «down»),
потому что поставщик crm.bp-gr.ru кладёт телефон/URL в поле «project» CSV.
Парсер extractPlatform() корректно их скипал, но строки оставались и в
count(missing), и в total_csv_rows формулы drift'а → стабильный false-positive.

Фикс (вариант A из брейнсторма с заказчиком):
- schema v8.36: +supplier_csv_reconcile_log.unparseable_count INTEGER NOT NULL DEFAULT 0
- CsvReconcileJob: считает $unparseableCount отдельно, новая формула
  drift = max(0, missing − unparseable) / max(1, total − unparseable)
- Миграция (pgsql_supplier, Спек B pattern, IF NOT EXISTS — idempotent)
- TDD: +2 теста (100matched+10junk → ok; mixed 95+5junk+3real → drift по реальным).
  Существующие 7 кейсов GREEN без изменений (unparseable=0 → формула идентична).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 08:38:31 +03:00
Дмитрий 0817c81e67 fix(admin): снять 503-замок saas-admin зоны — защиту держит nginx basic-auth
EnsureSaasAdmin fail-closed 503 вне dev/testing → вся админка на боевом
liderra.ru недоступна (все /api/admin/* падали). Настоящий saas-admin SSO
(Yandex 360) ещё не готов (Б-1 + DO-4), но держать зону наглухо закрытой
нельзя — заказчику нужна админка.

Стопгэп (выбор заказчика): защита /admin + /api/admin/* переносится на
nginx (отдельный HTTP Basic Auth, /etc/nginx/.htpasswd-admin), middleware
зону больше не закрывает. Тест production-кейса переведён с 503 на 200.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 07:35:03 +03:00
Дмитрий b2cbc57533 docs(brain): spec v2.3 + plan v1.2 — coverage announcement (§4.9) + decision confirmed
Coverage announcement — новая фича прозрачности (brainstorm 2026-05-25):
заказчик всегда видит чем покрыта задача (skill/node/hook/direct) в прозе
+ TodoWrite. Источник — classifier_output. Enforcement через economy-mode
reminder (economy сохраняется). Flag coverage-annotation-mode (10-й).
Тайминг — в составе overhaul Phase 3.

spec v2.2 → v2.3:
- §4.9 (новое) Coverage announcement — 2 поверхности, формат пометки,
  источник, enforcement, flag, тайминг, откат.
- §10 flags table +coverage-annotation-mode (9→10 флагов).
- §11.3 Phase 3 +task coverage announcement.
- §23 (новое) changelog v2.2→v2.3.

plan v1.1 → v1.2:
- DECISION POINT  ПОДТВЕРЖДЁН: economy keep / §12 remove.
- Task 19 +step 8 coverage announcement (economy-mode reminder + Pravila §17
  подпункт + memory feedback_coverage_announcement + flag).

brainstorm Q&A: Q1 поверхности=проза+TodoWrite; Q2 состав=skill+node+hook+direct;
Q3 enforcement=convention+reminder; Q4 тайминг=в составе overhaul.

НЕ исполняется (per user — план).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 07:31:33 +03:00
Дмитрий 7d31d0be39 docs(brain): plan v1.1 — откат мозга первым + 9 пробелов 0%-аудита
v1.0 → v1.1 после полного 0%-аудита плана.

ГЛАВНОЕ: Task 1 = «Откат мозга» — полная инфра + snapshot user-level
(~/.claude/settings.json + hooks/*.py + runtime flags) + dry-run +
END-TO-END SMOKE (тривиальная правка → откат → verify) ДО любой
деструкции. Если откат не зелёный — дальше не идём.

9 закрытых пробелов:
- G3 (КРИТИЧЕСКОЕ): user-level hooks смешивают economy-mode (0%/5%/100%,
  СОХРАНИТЬ) и §12 skill-discipline (СНЯТЬ). Task 2 разделяет: snimaet
  skill-marker.py+skill-check.py, оставляет economy-*.py, чистит §12 из
  economy-state-guard.py + economy-mode.py. DECISION POINT для заказчика.
- G16: brain-retro-opus-reviewer.mjs НЕ существует → Task 18 CREATE
  (не «keep from v2.0» как было в v1.0/spec).
- G11: router-accuracy-runner.mjs:11 import classifyByRegex сломается →
  Task 10 чинит на regex-fallback модуль.
- G14: registry-to-classification-map.mjs нейтрализуется (Task 4).
- G8/G9: C1/C2 адаптируются рано (Task 6), чтобы lefthook не блокировал
  коммиты Task 7-15.
- G5: parser forward-compat к v4 эпизодам после отката (Task 15).
- user-level rollback + episodes preservation в test-rollback.mjs/ROLLBACK.md.

Прочее: test-rollback.mjs использует execFileSync (не execSync — без shell,
без инъекции). 21 задача (было 22 в v1.0, консолидация rollback в Task 1).

Self-review: spec coverage + 16 находок v2.2 + 9 находок аудита плана,
type consistency, 0 placeholders.

НЕ исполняется сейчас (per user «только план»).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 06:31:11 +03:00
Дмитрий 2b7a71c5b6 docs(brain): implementation plan фазы 1+2+3 LLM-first router overhaul
План из spec v2.2 — 22 задачи (Task 0 pre-flight + 8 phase-1 + 8 phase-2
+ 6 phase-3) в bite-sized TDD-формате.

Phase 1 (foundation+archive): tag + archive scaffold + inventory hooks +
discipline-metrics decision + test-rollback.mjs (TDD) + archive §12/routing-
docs/memory + §17+ADR-016 + cross-refs §12→§17 + flags+ROLLBACK.md.

Phase 2 (classifier): router-config + nodes.yaml capabilities + prefilter
3 группы/manual-override/anchor (TDD) + Sonnet 4.6 classifier+памятка (TDD)
+ missed-activations на nodes.yaml (TDD) + embedding (TDD) + §17 gate (TDD,
D1 continuation-not-exempt) + prehook inheritance+cost (TDD) + parser v4.0
+ C1/C2 adapters + warmup hook + flags flip.

Phase 3 (evidence loop): Stop-hook execution_trace+chain_gaps+inheritance-
copy (TDD) + self_assessment (TDD) + reviewer subagent verify + direct-API
fallback handler + sanity-generator + brain-retro v2 procedure + self-
retrospect skill + analyzer v4 + status-md cost sections + schema v4.3 +
final flags + rollback dry-run verification.

Self-review: spec coverage (8 слоёв + §17 + 16 находок v2.2), 0 placeholders
(кроме намеренного model-ID резолва Task 0/9), type consistency проверена.

Реализация — после Биллинга v2 Спек C. Фаза 4 (distillation) — отдельный
план через ~6 месяцев. НЕ исполняется сейчас (per user).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 06:08:08 +03:00
Дмитрий af441961d9 fix(router): LLM Layer 2 через ProxyAPI с отдельным ключом ROUTER_LLM_KEY
router-classifier больше не ходит в недоступный api.anthropic.com и не читает ANTHROPIC_API_KEY (это перехватывало основную сессию Claude Code с подписки). callAnthropicAPI теперь ходит в ProxyAPI по умолчанию, ключ берёт из отдельной ROUTER_LLM_KEY, базовый URL — ROUTER_LLM_BASE_URL (опционально). Нет ключа → Layer 2 тихо выключен, откат на regex. +6 тестов (30/30 GREEN).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 06:07:02 +03:00
Дмитрий 2ec8707a03 docs(pilot): 25.05 — incident supplier:session РАЗРЕШЁН (бага нет, auto-refresh работает)
TTL-арифметика 2 cron-тиков (implied write 02:01:09 + 03:01:08 = journalctl
DONE) доказала: cron обновляет сессию каждый час. Ранний вывод «zombie lock»
был ошибкой замера. Root cause 3-дневного простоя — worker бежал со stale
--timeout=60 (timeout.conf=300 от 22.05 не подхвачен без рестарта); recycle
00:18 UTC подхватил правильный конфиг → самовосстановление.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 06:04:09 +03:00
Дмитрий 81f52fd1c6 docs(brain): spec v2.2 — закрыты 16 находок 0%-аудита
v2.1 → v2.2. Полный critical audit (экономия 0%) нашёл 16 пробелов;
все закрыты. Новый §22 — changelog с таблицей всех находок.

Критическое (1):
- D1: §17 formal text (§6) включал Continuation в exempt-список direct,
  но Continuation наследует non-conversation classification → §17 enforce
  должен применяться. §6 п.4 переписан: exempt только Acknowledgment +
  Cancellation; Continuation — нет (NON_BLOCKING_TASK_TYPES не содержит её).

Внутренние несогласованности (6):
- A1: schema alternative_better типизирован только null → заметка string|null.
- A2/C3: task_cost reviewer tokens vs usd — взаимоисключающи по пути review.
- A3: §11.3 step 3.2 «создать reviewer-agent.md» → файл уже создан (49aa4ba7).
- A4: prompt-enrichment risk добавлен в §14.
- A5/B3: missed-activations.mjs читает archived map → adapter task §11.2 task 9
  (переключение на nodes.yaml) + §9.5 detail + §14 risk.
- A6: §18.7 «reviewer-agent будет добавлен» → уже добавлен.

Underspecified (5):
- B1/B5: inheritance state→episode chain описан (§4.1 проверка 2).
- B2: reviewer fallback path = brain-retro-opus-reviewer.mjs сохраняется из v2.0.
- B4: discipline-metrics.mjs keep/remove → task §11.1 task 17.

Typo / ellipsis (4):
- C1: «existed» → «создана» в DoD.
- C4: «ОНLY» (кириллица) → «ONLY» в reviewer-agent.md.
- C5: anchor list финализирован (28 существительных + 10 императивов, ellipsis убран).

Сравнение аудитов: v1.0 имел 35 находок, v2.1 — 16, обе волны закрыты.
Остаточные implementation-вопросы (model ID, clustering algo) — в §15,
решаются на старте фаз.

Реализация — после закрытия Биллинга v2 Спек C.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 06:02:33 +03:00
Дмитрий 455bc1439b docs(pilot): 25.05 день — Биллинг v2 Спек C Phase 1 backend готов на ветке (прод НЕ затронут)
Снимок-заметка: feature-фаза, на боевой liderra.ru ничего не выкачено.
Ветка feat/billing-v2-spec-c HEAD d8955f57 (9 ahead of main).
Phase 1 preflight баланса: заморозка cut-off 18:00 MSK + 409 при
перегрузке + 4 письма + initial-sweep. Schema v8.36. Pest 13/13.
Осталось: Task 1.10 frontend + Phase 2-5 (VTB безнал).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 05:54:31 +03:00
Дмитрий 000c196e51 docs(pilot): 25.05 утро — recovery supplier:session (3 дня тишины у Компании 1)
Корень: Redis ключ liderra-database-liderra-cache-supplier:session был пуст
во всех DB. SupplierPortalClient не мог авторизоваться → CSV recovery=0,
sync 7 дней пустой, поставщик не активировал выдачу.

Hot-fix: dispatchSync через cat-pipe tinker stdin (quirk #109 workaround).
TTL 5ч59м. CsvReconcileJob drift=0.425 — false-positive (мусор в project field).

scheduler+worker+cron конфигурация правильная (timeout=300, hourly entry,
liderra-queue active, /etc/cron.d/liderra-scheduler каждую минуту).
journalctl показал DONE каждый час, но Redis пуст — вероятно zombie lock
после 22.05 worker-крахов. Точный root cause не установлен; auto-verify
в 02:00 UTC.

Все 275 lead_charges Компании 1 = prepaid (старая схема); ни одного
charge_source=rub за всю историю прода. Биллинг v2 Phase A ждёт первого
нового лида чтобы впервые применить tier-lookup живьём.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 04:32:07 +03:00
Дмитрий 49aa4ba725 docs(brain): spec v2.1 + reviewer-agent — 16 правок после code review
v2.0 → v2.1 — 3 группы изменений (16 пунктов суммарно):

Группа 1 — решения принятые после v2.0, не внесённые:
- 1.1 Памятка classifier (4 паттерна: brainstorming / discovery-interview /
  writing-plans / systematic-debugging). +flag prompt-enrichment-mode.
- 1.2 Reviewer как полноценный Claude Code subagent (tools=[Read,Grep,Glob,
  Skill], model=opus). Новый файл .claude/agents/reviewer-agent.md.
  +стоимость $240-1200/мес vs $40-80 direct API. Crash fallback на direct
  API. Context bloat cap 10 соседних эпизодов.
- 1.3 Inheritance + 3 группы коротких prompt'ов (continuation/acknowledgment/
  cancellation) + 30-минутный таймаут. +flag inheritance-mode. Новые поля
  в schema v4.1: inherited_from_task_id, inheritance_age_minutes,
  previous_direction_rejected, previous_task_id_rejected.

Группа 2 — edge cases:
- 2.1 Reviewer model явно opus в agent file.
- 2.2 Reviewer subagent crash → fallback direct API call.
- 2.3 Reviewer context bloat: max 10 episodes в agent system prompt.
- 2.4 Manual override приоритет №1 в prefilter (раньше inheritance).
- 2.5 Cancellation clears state + previous_task_id_rejected marker.

Группа 3 — мелкие упущения:
- 3.1 brain-retro SKILL.md description: раз в 1-2 недели (не sprint).
- 3.2 recommended_chain_id nullable для custom chains.
- 3.3 Embedding только для non-prefilter эпизодов.
- 3.4 PII filter wraps sanity-check comments.
- 3.5 requested_node fuzzy matching fallback.
- 3.6 Anchor word list inline initial.
- 3.7 Self-retrospect counter init в фазе 3 step 3.3.
- 3.8 Sanity-check answer file schema_version=1.

Cost rewrite: 720-1380 USD (v2.0) -> 1940-8200 USD (v2.1) на 6 месяцев
из-за reviewer subagent. Granular rollback через reviewer-mode=direct-api
возвращает к v2.0 ценам.

§21 новый — changelog v2.0 → v2.1 со всеми 16 пунктами и где правка.

Реализация — после закрытия Биллинга v2 Спек C.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 04:23:33 +03:00
Дмитрий 10eed4e7e4 docs(brain): spec v2.0 LLM-first router overhaul
Brainstorming сессия 2026-05-24..25:
- LLM-first классификатор (Sonnet 4.6) заменяет regex Layer 1.
- §17 «universal skill-coverage» заменяет §12 (default-deny кроме
  conversation/micro/manual_override/escape-hatch).
- Opus 4.7 ревьюер в /brain-retro заполняет review.* (auto, не пользователь).
- Self-retrospect skill (opt-in) для самоанализа агента.
- 7 гранулярных flag-переключателей + dry-run rollback.
- 3 implementation фазы (~3.5-4.5 недели) + 5-6 месяцев passive
  evidence collection + фаза 4 distillation regex из эмпирики.

v1.0 содержал 8 фактических ошибок (несуществующие skill-discipline
файлы, отсутствующие nodes.yaml поля, L1-L16 в неверных файлах) +
11 пропусков охвата + 10 underspecified + 6 противоречий. v2.0 —
полная перезапись с реальным state inventory (§18) и explicit
accepted trade-offs (§19).

Реализация — после закрытия Биллинга v2 Спек C (per user decision).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 03:27:02 +03:00
Дмитрий af6c328933 docs(billing-v2-c): спек C + план реализации (preflight + VTB)
Спек: preflight баланса на cut-off 18:00 MSK (защита от заказа лидов
клиентам без денег) + VTB-эквайринг через TopupGateway-интерфейс
(безнал полный, СБП/карты dev-заглушки до Б-1).

План: 6 фаз TDD-разбивкой, ~30 задач, subagent-driven-development
с git-verify-протоколом per Pravila §15.1.

Брейнсторм 24.05.2026, реализация стартует 25.05.

Lint: гибрид «Преfflight»→«Префлайт» (опечатка предыдущей сессии),
+6 терминов в cspell (Atol/uniqid/ОФД/брейнсторме/префлайт/Префлайт).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 03:09:07 +03:00
Дмитрий 2e2abe0e53 docs(pilot): legacy webhook removal — выкачено на боевой 24.05 (инцидент + fix + retry + smoke OK)
Phase 6 deploy. 13 commits + fix d377d977 чужей миграции. Инцидент 15:52 UTC →
rollback c7f603aa → fix + повторный deploy → миграция применилась за 57.85ms.
БД: webhook_log/rejected_deals_log/tenants.webhook_token* DROPPED;
webhook_dedup_keys ЖИВА. Портал HTTP 200, шеринг-канал жив.

+3 слова в cspell-words.txt (pre-existing typos в старых snapshot'ах).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 19:54:55 +03:00
Дмитрий d377d97737 fix(migration): 2026_05_22_000002 — use pgsql_supplier connection (owner-rights fix)
Миграция падала на проде:
  SQLSTATE[42501]: Insufficient privilege: must be owner of table webhook_log

Причина: default connection 'pgsql' (crm_app_user) не имеет owner-прав на
webhook_log (owner — crm_migrator). Заменено на 'pgsql_supplier'
(BYPASSRLS-роль crm_supplier_worker) — паттерн Спека B Phase 1 (commit 546ca30a),
который выработан ровно под эту проблему prod-ролей.

Эта миграция блокировала выкатку legacy-webhook-removal (Phase 6 deploy
24.05.2026, отменено rollback'ом). После fix миграция применится
no-op (webhook_log будет дропнут моей миграцией 2026_05_24_140000
сразу после).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 19:29:05 +03:00
18 changed files with 6524 additions and 51 deletions
+231
View File
@@ -0,0 +1,231 @@
---
name: reviewer-agent
description: |
Independent reviewer of routing decisions for Лидерра brain governance.
Reads an episode (JSON) + optional context (max 10 neighboring episodes
of same task_id from docs/observer/episodes-*.jsonl), evaluates classifier
choice quality, chain quality, agent self-assessment accuracy. Returns
structured JSON review.
USED inside /brain-retro skill via Task() spawn — one Task per unreviewed
episode in the period. NEVER edits files. NEVER commits. NEVER touches
nodes.yaml / episodes / нормативку.
Escalates to controller if episode is malformed or schema unknown.
Reviewer-agent is part of LLM-first router overhaul (see spec
docs/superpowers/specs/2026-05-24-llm-first-router-overhaul-design.md
§4.6 v2.1). Replaces direct Opus API call (v2.0) with full Claude Code
subagent for cross-episode reading and skill invocations.
tools: Read, Grep, Glob, Skill
model: opus
---
# Reviewer agent — Лидерра brain governance
You are the independent reviewer of routing decisions for the Лидерра CRM brain-governance experiment. Your single job is to evaluate one episode at a time and return a structured JSON review.
You DO NOT edit files. You DO NOT commit. You DO NOT modify the episode you are reviewing. You DO NOT make architectural decisions. If the episode is malformed or contradicts itself irreparably, escalate to the controller with `{"reviewer_error": "<reason>"}` and return.
## Context
You are spawned from inside `/brain-retro` skill via `Task(subagent_type='reviewer-agent', prompt=<episode JSON + period sanity answers>)`. Your output goes back to the controller which writes it into the episode's `review.*` fields.
Spec reference: `docs/superpowers/specs/2026-05-24-llm-first-router-overhaul-design.md` §4.6.
## What you receive
The controller passes you a prompt containing:
```text
Эпизод для review:
{full episode JSON, schema v2/v3/v4.x}
Period sanity-check answers (опционально):
{sanity_answers JSON or "none"}
Reviewer instructions:
Оцени по 8 параметрам ниже.
Return ONLY JSON, no prose.
```
## What you can read additionally (context)
Use `Read`, `Grep`, `Glob` to fetch:
1. **Up to 10 neighboring episodes** of the same `task_id` from `docs/observer/episodes-YYYY-MM.jsonl`. Use Grep to find them by `task_id`. **HARD LIMIT: 10**. If more exist, take the 10 closest in time.
2. **`docs/registry/nodes.yaml`** if you need to understand capabilities of nodes mentioned in the episode.
3. **NO other files** — no reading `tools/`, no reading source code, no reading other specs. Stay focused.
## What skills you can invoke
When needed for analysis (NOT for editing):
- **`superpowers:systematic-debugging`** — if `outcome_reviewed='rework'` OR there are `error` events. Apply 3-hypothesis methodology to identify `error_root_cause`.
- **`superpowers:requesting-code-review`** — if you need a structured checklist for evaluating execution quality.
- **`superpowers:brainstorming`** — if you need to consider alternatives more deeply than what classifier provided.
Skills are tools for YOUR thinking. They don't change anything. After invocation, return back to evaluating the episode.
## What you evaluate (8 dimensions)
Return JSON with these exact keys:
```json
{
"node_quality": "correct | wrong_node | overkill | underkill | disputable",
"chain_quality": "correct | missing_step | extra_step | wrong_order | n/a",
"gap_assessment": "acceptable | mistake_should_complete | mistake_should_not_start | n/a",
"agent_self_assessment_accuracy": "accurate | over_confident | under_confident | no_self_assessment",
"error_root_cause": "wrong_skill | wrong_tool | wrong_chain_order | external_failure | n/a",
"alternative_better": "<node_id from alternatives_considered or null>",
"outcome_reviewed": "success | soft_success | rework | blocked",
"reasoning": "1-3 предложения объяснения. Конкретно, не общо."
}
```
### Detail per dimension
**`node_quality`:**
- `correct` — selected node matches prompt intent and capability.
- `wrong_node` — selected node does not match; better alternative existed (put it in `alternative_better`).
- `overkill` — node is more heavy than needed (e.g., systematic-debugging for typo fix).
- `underkill` — node is too light (e.g., direct edit for security-sensitive area).
- `disputable` — reasonable but not obviously best.
**`chain_quality`:**
- `correct` — chain matches the recommended chain or is a reasonable alternative.
- `missing_step` — important step skipped (e.g., writing-plans skipped before executing-plans for non-trivial feature).
- `extra_step` — unnecessary step added.
- `wrong_order` — steps executed in wrong order.
- `n/a` — single-node task, no chain.
**`gap_assessment`** (only if `chain_gaps[].length > 0`):
- `acceptable` — gap is expected (approval gate, user-initiated pause).
- `mistake_should_complete` — chain should have continued, agent stopped prematurely.
- `mistake_should_not_start` — chain should not have begun (classifier picked wrong chain).
**`agent_self_assessment_accuracy`:**
- Сравни `self_assessment.confidence_in_choice` с реальным `outcome_inferred`/`outcome_reviewed`.
- `confidence ≥ 0.7 + outcome=rework``over_confident`.
- `confidence ≤ 0.4 + outcome=success``under_confident`.
- Соответствие → `accurate`.
- `self_assessment_pending: true``no_self_assessment`.
**`error_root_cause`** (only if `events.error.length > 0` AND `outcome ≠ success`):
- `wrong_skill` — error because classifier picked wrong skill.
- `wrong_tool` — error from tool within correct skill (e.g., Edit instead of MultiEdit on multi-occurrence).
- `wrong_chain_order` — error from misordered chain steps.
- `external_failure` — network/lock/race/API-down (not agent's fault).
- `n/a` — no error or success outcome.
**`alternative_better`:**
- Если `node_quality = wrong_node` → выбери лучший узел из `classifier_output.alternatives_considered[].node`.
- Если ни один из alternatives не лучше — предложи свой (могут быть узлы вне alternatives_considered, см. `docs/registry/nodes.yaml`).
- Иначе → `null`.
**`outcome_reviewed`** (proxy — закрывает 19.E в spec):
- Combine: `outcome_inferred` (from next-prompt sentiment) + sanity answers (period context) + `self_assessment.confidence` vs actual.
- `success` — task completed and user moved on positively.
- `soft_success` — task completed but with caveats (corrections, partial).
- `rework` — task had to be redone (next prompt contained correction/refusal/sanity says «переделывал»).
- `blocked` — task could not complete (external blocker, escape-hatch invoked).
**`reasoning`:**
- 1-3 предложения объяснения твоего решения.
- Конкретно: ссылайся на episode fields, not general principles.
- Если использовал cross-episode context — упомяни.
## Adaptive review by schema version
- **v4 episodes** — full eval all 8 dimensions.
- **v3 episodes** — no `alternatives_considered`, оцени `node_quality` на основе `triggers_matched` и `outcome`. `alternative_better` ставь null.
- **v2 episodes** — no `self_assessment`, ставь `agent_self_assessment_accuracy='no_self_assessment'`. Остальное как обычно.
- **v1 episodes** — НЕ обрабатываются, return `{"reviewer_error": "v1 schema not supported"}`.
## What you DON'T do
- Не редактируешь episode (controller сам пишет review.* поля по твоему JSON output).
- Не правишь nodes.yaml.
- Не правишь spec.
- Не делаешь коммиты.
- Не общаешься с пользователем — твой output идёт controller'у.
- Не читаешь больше 10 соседних эпизодов (cost cap).
- Не читаешь tools/* / source code — это вне scope review.
## Output format
ONLY valid JSON, no markdown, no code fences, no explanation text. Controller парсит твой output напрямую как JSON.
Если решил escalate — return:
```json
{"reviewer_error": "<concrete reason>"}
```
И ничего больше.
## Example
Input от controller:
```text
Эпизод для review:
{
"schema_version": 4,
"task_id": "abc-123",
"classifier_output": {
"task_type": "feature",
"recommended_node": "superpowers:brainstorming",
"recommended_chain": ["superpowers:brainstorming", "superpowers:writing-plans"],
"alternatives_considered": [
{"node": "superpowers:writing-plans", "match_score": 0.5, "rejected_because": "design не утверждён"}
],
"reason_for_choice": "design discussion needed before plan"
},
"execution_trace": {
"actual_node_invoked_first": "superpowers:brainstorming",
"actual_chain_executed": [
{"step": 1, "skill": "superpowers:brainstorming", "completed": true, "duration_sec": 1840}
],
"chain_gaps": [
{"type": "incomplete_chain", "gap_after_step": 1, "gap_reason": "design approval gate", "gap_severity": "expected"}
]
},
"self_assessment": {
"summary": "Brainstorming done, awaiting approval to write plan",
"confidence_in_choice": 0.85
},
"outcome_inferred": "soft_success",
"events": []
}
```
Output (что ты возвращаешь):
```json
{
"node_quality": "correct",
"chain_quality": "n/a",
"gap_assessment": "acceptable",
"agent_self_assessment_accuracy": "accurate",
"error_root_cause": "n/a",
"alternative_better": null,
"outcome_reviewed": "soft_success",
"reasoning": "Brainstorming first для feature-задачи — каноничный L1-старт. Gap after step 1 ожидаем: дизайн нуждается в approval. Self-assessment confidence=0.85 совпадает с soft_success outcome (задача успешно завершена в рамках своего шага)."
}
```
## Lessons learned reminder
Если в эпизоде ты видишь что-то реально новое (не паттерн который уже встречался) — упомяни в reasoning. Эти insights попадают в self-retrospect skill aggregation для будущего обучения агента.
Но НЕ делай self-retrospect сам — это отдельный skill.
+13 -15
View File
@@ -11,28 +11,26 @@ use Symfony\Component\HttpFoundation\Response;
/**
* Гейт SaaS-admin зоны (/api/admin/*) audit-находка J2.
*
* СТАБ (Sprint 3F): полноценная авторизация saas-admin требует Yandex 360
* SSO-входа, который гейтится Б-1 (регистрация ООО) + DO-4. До их закрытия
* реального механизма аутентификации нет.
* СТОПГЭП (2026-05-25): защита боевой админ-зоны (/admin + /api/admin/*)
* перенесена на уровень nginx отдельный HTTP Basic Auth с собственным
* паролем (`/etc/nginx/.htpasswd-admin`, location ^~ /admin и ^~ /api/admin).
* Поэтому middleware больше не закрывает зону на проде: дверь держит nginx.
*
* Поведение стаба:
* - dev / testing (local, testing) пропускаем. Admin-панель работает на
* dev; admin_user_id передаётся параметром (трейт ResolvesAdminUserId).
* - прочие окружения (production / staging) fail-closed 503: зона
* закрыта до подключения реального SSO. Явный 503 лучше, чем тихо
* открытый /api/admin/* в проде.
* Ранее (Sprint 3F) здесь был fail-closed 503 вне dev/testing он закрывал
* всю админку на проде наглухо, т.к. настоящий saas-admin SSO (Yandex 360)
* ещё не готов (гейтится Б-1 + DO-4). Замок 503 снят осознанно: оголять
* /api/admin/* в интернет нельзя, но nginx-пароль её прикрывает.
*
* TODO (после Б-1 + DO-4): заменить на проверку Yandex 360 SSO-сессии
* saas-admin (отдельный guard) + роль (compliance и т.п. где требуется).
* admin_user_id для audit-trail по-прежнему резолвится трейтом
* ResolvesAdminUserId (стаб super_admin) это отдельная зона.
*
* TODO (после Б-1 + DO-4): заменить nginx-дверь на настоящий saas-admin
* guard (Yandex 360 SSO-сессия + роль), вернуть проверку в это middleware.
*/
class EnsureSaasAdmin
{
public function handle(Request $request, Closure $next): Response
{
if (! app()->environment('local', 'testing')) {
abort(503, 'SaaS-admin авторизация не настроена (ожидает Б-1 + DO-4).');
}
return $next($request);
}
}
+13 -1
View File
@@ -126,11 +126,15 @@ final class CsvReconcileJob implements ShouldQueue
$missing = array_diff_key($csvByKey, $existingKeys);
$recoveredCount = 0;
$unparseableCount = 0;
foreach ($missing as $row) {
$platform = $this->extractPlatform((string) $row['project']);
if ($platform === null) {
// Поставщик иногда кладёт в `project` нестандартные имена (телефон, URL).
// Не warning — это не наш баг, processing продолжается, paper-trail на info уровне.
// Считаем такие строки отдельно, чтобы исключить из формулы drift'а
// (иначе ~40-50% мусора каждый запуск стабильно даёт false-positive drift_alert).
$unparseableCount++;
Log::info('csv_reconcile.unparseable_project_skipped', [
'project' => $row['project'],
]);
@@ -161,7 +165,14 @@ final class CsvReconcileJob implements ShouldQueue
}
$matchedCount = $totalCsvRows - count($missing);
$driftRatio = $totalCsvRows > 0 ? count($missing) / $totalCsvRows : 0.0;
// drift считается только по «реальным» пропускам (parseable, не junk):
// real_missing = count(missing) - unparseable (всегда ≥ 0)
// parseable_tot = total_csv_rows - unparseable
// Это убирает класс «поставщик кладёт телефон/URL в поле project →
// строки скипаются → drift искусственно завышен» (см. ПИЛОТ 22.05, 25.05).
$realMissing = max(0, count($missing) - $unparseableCount);
$parseableTotal = max(0, $totalCsvRows - $unparseableCount);
$driftRatio = $parseableTotal > 0 ? $realMissing / $parseableTotal : 0.0;
$status = $driftRatio > self::DRIFT_THRESHOLD ? 'drift_alert' : 'ok';
$update = [
@@ -169,6 +180,7 @@ final class CsvReconcileJob implements ShouldQueue
'total_csv_rows' => $totalCsvRows,
'matched_count' => $matchedCount,
'recovered_count' => $recoveredCount,
'unparseable_count' => $unparseableCount,
'drift_ratio' => $driftRatio,
'status' => $status,
];
@@ -9,8 +9,11 @@ return new class extends Migration
{
public function up(): void
{
// Guard: only run if webhook_log exists (should always exist, but be safe)
if (! Schema::hasTable('webhook_log')) {
$conn = DB::connection('pgsql_supplier');
// Guard: only run if webhook_log exists (на проде после legacy-webhook-removal
// таблицы нет — миграция становится no-op).
if (! $conn->getSchemaBuilder()->hasTable('webhook_log')) {
return;
}
@@ -18,16 +21,18 @@ return new class extends Migration
base_path('/../db/migrations/2026_05_22_002_webhook_log_supplier_columns.sql')
);
DB::unprepared($sql);
$conn->unprepared($sql);
}
public function down(): void
{
if (! Schema::hasTable('webhook_log')) {
$conn = DB::connection('pgsql_supplier');
if (! $conn->getSchemaBuilder()->hasTable('webhook_log')) {
return;
}
DB::unprepared(<<<'SQL'
$conn->unprepared(<<<'SQL'
ALTER TABLE webhook_log
DROP COLUMN IF EXISTS source,
DROP COLUMN IF EXISTS status,
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* supplier_csv_reconcile_log + unparseable_count: количество CSV-строк
* за окно reconcile, у которых поле «project» не парсится в платформу
* (поставщик иногда кладёт телефон/URL в Name extractPlatform = null,
* строка скипается в csv_reconcile.unparseable_project_skipped).
*
* Раньше эти строки попадали в знаменатель drift_ratio и счётчик missing,
* стабильно завышая drift до ~40-50% (false-positive drift_alert каждый
* запуск). Теперь они учитываются отдельно и вычитаются из формулы.
*
* Используется в CsvReconcileJob + AdminSupplierIntegrationController.
* Таблица SaaS-level (без RLS), пишет/читает crm_supplier_worker
* (BYPASSRLS) pgsql_supplier connection.
*/
return new class extends Migration
{
public function up(): void
{
$conn = DB::connection('pgsql_supplier');
if (! $conn->getSchemaBuilder()->hasTable('supplier_csv_reconcile_log')) {
return;
}
$conn->unprepared(<<<'SQL'
ALTER TABLE supplier_csv_reconcile_log
ADD COLUMN IF NOT EXISTS unparseable_count INTEGER NOT NULL DEFAULT 0;
SQL);
}
public function down(): void
{
$conn = DB::connection('pgsql_supplier');
if (! $conn->getSchemaBuilder()->hasTable('supplier_csv_reconcile_log')) {
return;
}
$conn->unprepared(<<<'SQL'
ALTER TABLE supplier_csv_reconcile_log
DROP COLUMN IF EXISTS unparseable_count;
SQL);
}
};
+12 -8
View File
@@ -5,21 +5,25 @@ declare(strict_types=1);
use Illuminate\Foundation\Testing\DatabaseTransactions;
/**
* J2 (Sprint 3F) стаб-гейт SaaS-admin зоны.
* J2 (Sprint 3F) гейт SaaS-admin зоны.
*
* EnsureSaasAdmin на /api/admin/*: dev/testing пропускает (admin-панель
* работает на dev), прочие окружения fail-closed 503 до подключения
* реального Yandex 360 SSO (TODO под Б-1+DO-4).
* EnsureSaasAdmin на /api/admin/*: пропускает запрос во ВСЕХ окружениях.
* Защита боевой админ-зоны (/admin + /api/admin/*) перенесена на nginx
* (HTTP Basic Auth, отдельный пароль /etc/nginx/.htpasswd-admin), потому
* что настоящий saas-admin SSO (Yandex 360) ещё не готов (Б-1 + DO-4).
* Ранее middleware fail-closed 503 вне dev/testing это закрывало всю
* админку на проде наглухо; стопгэп заменил замок на nginx-дверь.
*/
uses(DatabaseTransactions::class);
test('/api/admin/* пропускается на testing-окружении (стаб permissive)', function () {
// Дефолтное тестовое окружение = testing → middleware пропускает.
test('/api/admin/* пропускается на testing-окружении', function () {
$this->getJson('/api/admin/tenants')->assertStatus(200);
});
test('/api/admin/* возвращает 503 вне dev/testing (стаб fail-closed)', function () {
test('/api/admin/* пропускается и на production (замок 503 снят, дверь держит nginx)', function () {
$this->app->detectEnvironment(fn () => 'production');
$this->getJson('/api/admin/tenants')->assertStatus(503);
// Раньше тут был 503. Теперь приложение зону не закрывает — её держит
// nginx basic-auth (стопгэп до реального Yandex 360 SSO).
$this->getJson('/api/admin/tenants')->assertStatus(200);
});
@@ -257,3 +257,80 @@ it('SupplierTransientException — status=failed, error recorded, rethrown', fun
expect($log->status)->toBe('failed');
expect($log->error_message)->toContain('500');
});
it('unparseable CSV rows excluded from drift: 100 matched + 10 junk-project rows → status=ok, unparseable_count=10', function (): void {
// 100 нормальных webhook-лидов.
for ($i = 0; $i < 100; $i++) {
SupplierLead::create([
'supplier_project_id' => null,
'platform' => 'B1',
'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT),
'vid' => 840000 + $i,
'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)],
'received_at' => now()->subHour(),
'source' => 'webhook',
]);
}
// CSV: те же 100 (matched) + 10 строк с мусорным project (extractPlatform = null).
// Это реальный паттерн поставщика — телефон в поле «Name» вместо проекта (см. 22.05 в ПИЛОТ).
$rows = [];
for ($i = 0; $i < 100; $i++) {
$rows[] = ['project' => 'B1_a.com', 'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)];
}
for ($j = 0; $j < 10; $j++) {
$rows[] = ['project' => '79135551234', 'phone' => '7999500000'.$j];
}
fakeReportFlow(csvBody($rows));
runCsvReconcile();
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
expect((int) $log->total_csv_rows)->toBe(110);
expect((int) $log->matched_count)->toBe(100);
expect((int) $log->recovered_count)->toBe(0);
expect((int) $log->unparseable_count)->toBe(10);
// Реального missing'а нет — только junk; drift должен быть 0, не 10/110.
expect((float) $log->drift_ratio)->toBe(0.0);
expect($log->status)->toBe('ok');
Mail::assertNothingSent();
});
it('mixed: 95 matched + 5 junk + 3 real-missing → unparseable_count=5, recovered=3, drift по реальным', function (): void {
for ($i = 0; $i < 95; $i++) {
SupplierLead::create([
'supplier_project_id' => null,
'platform' => 'B1',
'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT),
'vid' => 850000 + $i,
'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)],
'received_at' => now()->subHour(),
'source' => 'webhook',
]);
}
$rows = [];
for ($i = 0; $i < 95; $i++) {
$rows[] = ['project' => 'B1_a.com', 'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)];
}
for ($j = 0; $j < 5; $j++) {
$rows[] = ['project' => 'https://junk.example/'.$j, 'phone' => '7999600000'.$j];
}
for ($k = 0; $k < 3; $k++) {
$rows[] = ['project' => 'B1_a.com', 'phone' => '7999700000'.$k];
}
fakeReportFlow(csvBody($rows));
runCsvReconcile();
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
expect((int) $log->total_csv_rows)->toBe(103);
expect((int) $log->matched_count)->toBe(95);
expect((int) $log->recovered_count)->toBe(3);
expect((int) $log->unparseable_count)->toBe(5);
// real_missing = (103 - 95) - 5 = 3; parseable_total = 103 - 5 = 98; drift = 3/98 ≈ 0.0306 < 5% → ok.
expect((float) $log->drift_ratio)->toBeLessThan(0.05);
expect((float) $log->drift_ratio)->toBeGreaterThan(0.0);
expect($log->status)->toBe('ok');
});
+22
View File
@@ -1733,3 +1733,25 @@ dok
маунт
pgrep
захардкоженной
ребейза
токену
тултип
# Билинг v2 Спек C (25.05.2026)
Atol
uniqid
ОФД
брейнсторме
префлайт
Префлайт
скоупа
unreviewed
# admin-zone nginx-gate + drift-fix (25.05.2026 день+1)
стопгэп
досылает
creds
опкэш
гэп
misowned
деплоями
+29 -1
View File
@@ -2,7 +2,34 @@
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит тридцать записей в обратном хронологическом порядке (v8.33 → v8.32 → v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.
**Файл схемы:** `schema.sql` (текущая версия — v8.35, консолидированная — разворачивает БД с нуля).
**Файл схемы:** `schema.sql` (текущая версия — v8.36, консолидированная — разворачивает БД с нуля).
## v8.36 (2026-05-25) — supplier_csv_reconcile_log.unparseable_count: drift-формула без junk-строк
Поставщик `crm.bp-gr.ru` периодически кладёт телефон/URL в поле «project» CSV-выгрузки
«Запрос номеров». Парсер `CsvReconcileJob` корректно их скипает (`extractPlatform()``null`),
но раньше эти строки попадали и в числитель `count($missing)`, и в знаменатель `total_csv_rows`
формулы drift'а → стабильный false-positive `drift_alert` ~40-50% при каждом hourly-запуске
(на проде 10 запусков подряд → admin-блок «Здоровье резервного канала» показывал «down»).
**Добавлено:**
- **Колонка `supplier_csv_reconcile_log.unparseable_count` INTEGER NOT NULL DEFAULT 0** — кол-во
CSV-строк за окно, у которых `project` не парсится в платформу B1/B2/B3.
**Изменено:**
- `CsvReconcileJob`: считает `$unparseableCount` отдельно, новая формула
`drift_ratio = max(0, missing unparseable) / max(1, total unparseable)`
только «реальные» пропуски от parseable-строк, без вклада junk'а.
**Метрики:** +1 колонка. (Сверять с header `db/schema.sql`.) Таблиц / индексов / RLS — без изменений.
**Миграция:** `2026_05_25_100000_add_unparseable_count_to_supplier_csv_reconcile_log` (idempotent
`ADD COLUMN IF NOT EXISTS` на `pgsql_supplier` connection — Спек B pattern).
**Тесты:** `app/tests/Feature/Supplier/CsvReconcileJobTest.php` — +2 кейса (100 matched +
10 junk → status=ok / mixed 95+5junk+3real → drift по реальным). Существующие 7 кейсов — без изменений (drift при unparseable=0 идентичен старой формуле).
## v8.35 (2026-05-24) — legacy direct webhook removal
@@ -32,6 +59,7 @@
**Миграция:** `2026_05_24_140000_drop_legacy_webhook_artefacts`
**Связанные изменения кода:**
- `MonthlyPartitionManager::PARTITIONED_TABLES` — убрана строка `webhook_log`
- `PdErasureService::eraseSubject()` — убрана секция erasure по `webhook_log`
+7 -1
View File
@@ -1,6 +1,7 @@
-- =============================================================================
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
-- Версия: v8.35 (24.05.2026 — legacy direct webhook removal: DROP webhook_log (partitioned) + rejected_deals_log + tenants.webhook_token/webhook_token_rotated_at; webhook_dedup_keys сохранена (CSV-канал))
-- Версия: v8.36 (25.05.2026 — supplier_csv_reconcile_log.unparseable_count: учёт мусорных CSV-строк, вычитание из drift-формулы → убирает false-positive drift_alert от телефонов/URL в поле project)
-- Базовая версия: v8.35 (24.05.2026 — legacy direct webhook removal: DROP webhook_log (partitioned) + rejected_deals_log + tenants.webhook_token/webhook_token_rotated_at; webhook_dedup_keys сохранена (CSV-канал))
-- Базовая версия: v8.34 (23.05.2026 — Billing v2 Spec B: −индекс deals(duplicate_of_id) — телефонный дедуп удалён)
-- Базовая версия: v8.31 (23.05.2026 — партиционирование 7 audit-таблиц помесячно (hole #2): auth_log / activity_log / tenant_operations_log / balance_transactions / pd_processing_log / saas_admin_audit_log; PK → (id, created_at|received_at); retention defaults в system_settings)
-- Базовая версия: v8.30 (23.05.2026 — scheduler_heartbeats: пульс планировщика, SaaS-level без RLS, 11 cron-задач, hole #6)
@@ -1137,6 +1138,11 @@ CREATE TABLE supplier_csv_reconcile_log (
total_csv_rows INTEGER,
matched_count INTEGER,
recovered_count INTEGER,
-- Кол-во CSV-строк, у которых поле «project» не парсится в платформу B1/B2/B3
-- (поставщик иногда кладёт телефон/URL в «Name» вместо названия проекта).
-- Используется CsvReconcileJob для корректного расчёта drift'а — без вычитания
-- этих строк формула стабильно даёт false-positive drift_alert ~40-50%.
unparseable_count INTEGER NOT NULL DEFAULT 0,
drift_ratio NUMERIC(5,4),
status VARCHAR(16) NOT NULL DEFAULT 'running'
CHECK (status IN ('running','ok','drift_alert','failed')),
+14 -14
View File
@@ -1,22 +1,22 @@
# Brain Status (auto-generated)
Last updated: 2026-05-24T13:01:24.122Z
Last updated: 2026-05-25T04:31:41.337Z
| Контролёр | Состояние | Детали |
|---|---|---|
| C1 L1-watcher | ✅ | [l1-watcher] OK — 0 drift |
| C2 Cross-ref consistency | | [cross-ref-checker] OK — 0 drift in 4 files |
| C2 Cross-ref consistency | 🔴 | Update cross-refs in offending files. |
| C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 0 week(s) ago |
| C4 Сигнальный статус | ✅ | This file (self-reference) |
| C5 Observer-coverage | ⚠️ | 135 episode(s) this month · .git/hooks/post-commit not installed (run: npx lefthook install --force) · 17 missed activation(s) — see /brain-retro |
| C5 Observer-coverage | ⚠️ | 341 episode(s) this month · Stop-hook + post-commit OK · 21 missed activation(s) — see /brain-retro |
| C6 Chain map sync | ✅ | [chain-map-checker] OK — 16 chains in sync |
## Метрики (информационные, не алерты)
- Observer evidence: 135 episodes this month, 0 observer_error markers, 6 PII matches before filter
- Legacy v1 episodes (not in factor analysis): 11
- Last /brain-retro: 1 day(s) ago
- Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 17. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
- Observer evidence: 341 episodes this month, 0 observer_error markers, 31 PII matches before filter
- Legacy v1 episodes (not in factor analysis): 202
- Last /brain-retro: 0 day(s) ago
- Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 21. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
## Метрики дисциплины
@@ -24,17 +24,17 @@ Baseline дисциплины роутера (этап 2 router discipline overh
| Тип задачи | Эпизодов | % с триггер-матчем | % через скил |
|---|---|---|---|
| bugfix | 7 | 28.6% | 42.9% |
| feature | 5 | 0.0% | 0.0% |
| analysis | 4 | 0.0% | 25.0% |
| planning | 2 | 0.0% | 0.0% |
| analysis | 15 | 46.7% | 26.7% |
| monitoring | 12 | 0.0% | 0.0% |
| bugfix | 10 | 40.0% | 40.0% |
| planning | 9 | 11.1% | 22.2% |
| feature | 9 | 22.2% | 0.0% |
| refactor | 1 | 0.0% | 0.0% |
| cleanup | 1 | 0.0% | 0.0% |
| monitoring | 1 | 0.0% | 0.0% |
Router step distribution: 1: 55, 2: 45, 3: 12, 5: 18
Router step distribution: 1: 139, 2: 118, 3: 37, 5: 42
Boundaries applied (ADR / границы): 13 of 130 эпизодов (10.0%).
Boundaries applied (ADR / границы): 47 of 336 эпизодов (14.0%).
## Активные многоэтапные проекты
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,822 @@
# Спек C — Биллинг v2: preflight баланса + VTB-эквайринг
**Дата:** 2026-05-24
**Статус:** Design (awaiting user review)
**Автор:** Claude Opus 4.7 (под руководством заказчика)
**Брейнсторм:** сессия 24.05.2026
**Триггер:** «preflight баланса перед заказом у поставщика, чтобы не заказать лишнего; VTB-эквайринг для пополнения баланса» (заказчик 23.05.2026, расширено в брейнсторме 24.05).
**Часть серии из 3 спеков:**
- Спек A — единый ₽-баланс (DONE на проде, [спек](2026-05-23-billing-v2-spec-a-balance-rub-design.md)).
- Спек B — политика дублей (DONE на проде, [спек](2026-05-23-billing-v2-spec-b-duplicates-design.md)).
- **Спек C (этот)** — preflight баланса + VTB-эквайринг.
---
## §1. Контекст и проблема
### §1.1 Текущее поведение портала
**Расчёт заказа у поставщика** ([app/app/Services/Supplier/SupplierQuotaAllocator.php:88-98](../../../app/app/Services/Supplier/SupplierQuotaAllocator.php#L88-L98)):
```
order = max(самый_большой_лимит, ceil(сумма_лимитов ÷ 3))
```
где входной массив — `daily_limit` всех eligible-на-сегодня проектов клиентов на источнике (источник = тег × субъект). Затем `order` делится между площадками B1/B2/B3 (`distributeForPlatform`, largest-remainder).
**Allocator не смотрит баланс.** На вход принимает только лимиты проектов. Если у клиента нулевой баланс — он всё равно учитывается в формуле, значит портал заказывает у поставщика лиды, которые этот клиент оплатить не сможет.
**Списание с клиента** ([app/app/Services/Billing/LedgerService.php](../../../app/app/Services/Billing/LedgerService.php)) происходит при доставке (`RouteSupplierLeadJob` создаёт `Deal``LedgerService::chargeForDelivery`). Если баланса не хватает — после Спека A не было защиты «на входе»; шёл лид, списывалось, баланс уходил в ноль.
**Пополнение баланса** ([app/app/Services/Billing/BillingTopupService.php](../../../app/app/Services/Billing/BillingTopupService.php)) — MVP-stub: мгновенно кредитует `balance_rub` + пишет `balance_transactions(type='topup')`. Реальной оплаты нет. UI — `AdminTenantsController::adjustBalance` (admin-only).
### §1.2 Проблемы
**Префлайт:**
1. **Портал переплачивает поставщику** за лиды клиентов, у которых нет денег. Это прямой убыток — закупка оплачена, а продажа не состоится.
2. **Нет проактивной защиты при создании/изменении проектов.** Клиент может выставить лимит, который сам по себе не оплачиваем. Сейчас проблема всплывает только в момент списания.
3. **Нет ясной коммуникации с клиентом** «у тебя баланса хватит на X лидов, ты заказал Y, нужно пополнить или сократить» — клиент узнаёт по факту остановки списания.
**VTB-эквайринг:**
1. **Реального пополнения нет**`BillingTopupService` это заглушка. Деньги попадают на баланс только через ручное действие админа (`AdminTenantsController::adjustBalance`).
2. **Нет 54-ФЗ фискализации** при ритейл-платежах (после подключения карт/СБП — обязательно).
### §1.3 Триггер
Заказчик 23.05.2026: «preflight баланса перед заказом у поставщика; VTB-эквайринг; аудит раздела Биллинг» → в брейнсторме 24.05 уточнено как один спек с детальной механикой preflight + полный охват трёх методов оплаты (безнал, СБП, карты).
**Главный принцип** (заказчик 24.05): «**не заказать лишнего у поставщика — это убыток**».
---
## §2. Scope
### §2.1 Что делаем
**Префлайт баланса:**
- Расширение `SupplierQuotaAllocator` для учёта баланса клиента (фильтрация eligible-проектов до `computeOrder`).
- Активная проверка при создании/правке проекта в личном кабинете (диалог выбора).
- Активная проверка перед cut-off (18:00 MSK ежедневно) — для пассивного износа баланса.
- UI-баннер «приём приостановлен» в личном кабинете клиента.
- Уведомления по email с правильной частотой (1 + 1д + 3д + «возобновлено»).
- Журналирование событий заморозки/разморозки в `balance_transactions` или новой таблице.
**VTB-эквайринг:**
- Архитектура (интерфейс `TopupGateway` + три реализации: `BankTransfer`, `SBP`, `Card`).
- **Полная реализация Безнала** (генерация PDF-счёта, журнал «ожидающих платежей» в админке, авто-поиск через VTB Бизнес API с подтверждением человеком, режимный переключатель «автомат / с подтверждением», часовые email-алерты).
- **Dev-заглушки для СБП и Карт** (мгновенное подтверждение в dev/test, реальные эндпоинты VTB — после Б-1).
- **Архитектура 54-ФЗ** через ОФД-Атол (заглушка в dev, реальная интеграция после Б-1 параллельно с СБП).
### §2.2 Что НЕ делаем (явно out of scope)
- **Реальное подключение VTB Acquiring и СБП** — требует реквизитов ООО (P0-блокер Б-1). Отдельные задачи после Б-1.
- **Реальная интеграция ОФД-Атол** — параллельно с СБП после Б-1.
- **Авто-сверка с банковской выпиской VTB** (`/api/vtb-business`) — отдельная задача после Б-1; в этом спеке только архитектурный интерфейс и ручной режим.
- **«Отдать разморозившемуся клиенту лиды, уже купленные сегодня, через шеринг»** — отложено в Спек D (см. §8).
- **Возвраты пополнений** (refund) — не реализуем (Спек A: «возвраты не делаем»).
- **Recurring-платежи** (автосписание) — не реализуем.
- **Изменение формулы `computeOrder`** — формула остаётся прежней, префлайт только фильтрует входной список.
---
## §3. Решение — часть 1: Префлайт баланса
### §3.1 Главный инвариант
**Баланс клиента никогда не уходит в минус.** Гарант — префлайт, который проверяет «хватит ли на полный дневной заказ» **до** того, как заказ уйдёт поставщику. Если хватает — клиент в заказе; если поставщик пришлёт меньше планируемого (норма), остаток баланса уходит в следующий день.
### §3.2 Когда срабатывает префлайт
**Одна основная точка:** ежедневный cut-off в **18:00 MSK** (включая выходные).
Между cut-off и cut-off (всё внутри текущего дня) никаких внутридневных стопов нет. Лиды, заказанные у поставщика на сегодня, идут клиенту полностью, списываются с его баланса как поступают. Защита от ухода в минус — на стороне cut-off предыдущего вечера.
**Дополнительные триггерные точки** (для UX в личном кабинете, не для блокировки заказа):
- Создание нового проекта в UI клиента.
- Изменение лимита существующего проекта в UI клиента.
- Активация ранее приостановленного проекта.
- Пополнение баланса (для авто-разморозки при следующем cut-off).
- Снижение/удаление проекта (может вернуть в зелёную зону).
### §3.3 Что значит «баланса хватает»
Сравнение делается **в лидах**, не в рублях, потому что в existing-сервисе `BalanceToLeadsConverter` (от Спека A) есть прямой расчёт «сколько лидов даст баланс с учётом 7 ступеней и уже отгруженного объёма за месяц»:
```php
$capacity = $converter->convert(
balanceRub: $tenant->balance_rub,
deliveredInMonth: $tenant->delivered_in_month,
tiers: $activePricingTiers
)['leads'];
$requiredLeads = $tenant->projects()
->where('status', 'active')
->where('eligible_tomorrow', true)
->sum('daily_limit');
$passes = $capacity >= $requiredLeads;
```
Если `passes=true` — клиент проходит префлайт. Если `false` — не проходит.
7-ступенчатый расчёт уже реализован в `BalanceToLeadsConverter::convert` (Спек A) — он сам пройдёт по ступеням, учтёт «текущую» (где сейчас клиент в накопленном объёме) и переход на следующие при росте.
**NB:** проверяется на **полный лимит** проектов, не на «уже отгруженное + остаток сегодняшнего дня». Это потому, что префлайт работает один раз перед формированием заказа на завтра, а не во время выдачи.
### §3.4 Что делает портал при создании/правке «перегруженного» проекта
В UI клиента при попытке сохранить проект, после которого сумма `daily_limit` всех eligible-проектов превысит «потолок баланса»:
**Модальный диалог** (не блокирующая ошибка):
```
Этот лимит превышает твой баланс.
У тебя на счёте 1000₽ = 30 лидов по текущему тарифу.
После сохранения этого проекта сумма лимитов будет 40 лидов.
Не хватает: 10 лидов.
Чтобы он начал работать, нужно одно из:
• Пополнить счёт (примерно 350₽ покроют 10 лидов недостачи)
• Поставить лимит этого проекта 0
• Уменьшить лимиты других проектов
[Сохранить и приостановить только этот] [Поставить лимит 0] [Отмена]
```
- **«Сохранить и приостановить только этот»** — проект сохраняется с исходным лимитом, но при следующем cut-off исключается из расчёта заказа на завтра (`active_today = false`). Остальные проекты клиента работают как обычно.
- **«Поставить лимит 0»** — проект сохраняется с лимитом 0 (фактически выключен). Не идёт в заказ.
- **«Отмена»** — изменения отбрасываются.
**Ключевое:** заморозка точечная, **только перегружающий проект**. Не «весь тенант». Это позволяет клиенту работать над созданием 20-30 проектов поэтапно, постепенно понимая «нужно столько-то ₽ для запуска всех».
### §3.5 Что делает портал при пассивном износе баланса
Клиент не правил проекты, просто баланс таял по дням. На очередном cut-off (18:00 MSK) выясняется, что баланс уже не покрывает все активные проекты.
**Действие:**
1. **Все проекты клиента** исключаются из расчёта заказа на завтра.
2. На `tenants` устанавливается флаг `frozen_by_balance_at = now()` (новая колонка).
3. На email клиента — письмо «Приём лидов приостановлен» (детали §3.7).
4. В личном кабинете — красный баннер на всех страницах (детали §3.6).
Клиент сам выбирает что делать в личном кабинете:
- Пополнить баланс (откроет UI «Пополнение», см. часть 2).
- Снизить лимиты на проектах (UI «Проекты»).
- Выключить часть проектов (paused).
- Любое сочетание.
Как только сумма лимитов снова влезает в баланс (после пополнения, снижения лимита, или выключения проектов) — `frozen_by_balance_at = NULL`, баннер исчезает, отправляется письмо «Возобновлено» (если успели до 18:00 — клиент в завтрашнем заказе; если позже — в послезавтрашнем).
### §3.6 UI личного кабинета клиента
**Красный баннер на всех страницах** (компонент `BalanceFrozenBanner.vue`) когда `tenant.frozen_by_balance_at IS NOT NULL`:
```
🔴 Приём лидов приостановлен
Не хватает баланса на дневной заказ. Нужно ещё 380₽ (или сократи лимиты на 10 лидов).
[Пополнить счёт] [Перейти к проектам]
```
**Постоянная подсказка под балансом** (даже когда не в заморозке) — компонент `BalanceCapacityIndicator.vue`:
```
Баланс: 1000₽ = до 30 лидов по тарифу
Проекты заказывают: 25 лидов в день
✅ Хватит на ~1.2 дня
```
В состоянии «хватает на меньше 3 дней» — жёлтый цвет с подсказкой «скоро потребуется пополнение». В состоянии «не хватает» — красный (баннер выше).
### §3.7 Email-уведомления
**При входе в заморозку:**
- T+0 (сразу) — `BalanceFrozenMail` («Приём лидов приостановлен»).
- T+24ч (если ещё в заморозке) — `BalanceFrozenReminderMail` («Всё ещё приостановлено»).
- T+72ч (если ещё в заморозке) — `BalanceFrozenFinalMail` («Приостановлено 3 дня»).
- Дальше — тишина до следующего цикла (если разморозится и снова попадёт — счёт идёт заново).
**При выходе из заморозки:**
- T+0 — `BalanceUnfrozenMail` («Приём возобновлён»).
Все письма throttled по `tenant_id` через `mail_log` (паттерн `ZeroBalancePausedMail` из Plan 4) — повторов не будет.
### §3.8 Cut-off режимы синхронизации с поставщиком
Сохраняем оба существующих режима (admin-переключатель уже есть):
- **Онлайн-режим** (сейчас, для малого числа клиентов): любое изменение в проектах Лидерры → немедленный апдейт на сервере поставщика (`SyncSupplierProjectJob` per-project). Поставщик сохраняет, использует в своём 21:00 слепке.
- **Batch до 18:00** (на будущее при росте): накопленные изменения уезжают одним пакетом перед 18:00 (`SyncSupplierProjectsJob` daily cron).
**Префлайт работает одинаково в обоих режимах** — он только меняет, какие проекты идут в `SyncSupplierProjectJob` (исключает frozen-проекты из `active_today`). Дальше — стандартный механизм синхронизации.
### §3.9 Граничные случаи
| Случай | Поведение |
|---|---|
| Ретро-операция (CSV-импорт исторических лидов) между 18:00 и началом следующего дня списывает баланс ниже плана | Допускается, но админ предупреждается в UI «эта операция может вывести клиента в заморозку, продолжить?». Если согласился — выполняется; на следующем cut-off клиент будет в заморозке. Минусовых балансов не возникает (CSV-импорт делает обычные `lead_charges` через `LedgerService`, который остаётся защищён от минуса) |
| Ручная правка баланса админом (`adjustBalance`) уменьшает баланс ниже плана | Аналогично — админ предупреждается, ответственность на нём. Префлайт отработает на следующем cut-off |
| Клиент уже в минусовом балансе на момент запуска префлайт (legacy состояние) | Одноразовая artisan-команда `billing:preflight-initial-sweep` — проходит по всем тенантам, помечает `frozen_by_balance_at` где нужно, отправляет письма с пояснением «у вас активирована новая защита баланса». Запускается один раз при выкатке миграции |
| Тарифная ступень меняется в течение дня (накопился объём) | Префлайт на 18:00 MSK использует **текущую** ступень. На завтра ступень может быть другой — но это уже зона следующего cut-off |
| Поставщик прислал меньше планируемого (норма) | Остаток баланса клиента — экономия для следующего дня. Никаких корректировок |
| Клиент пополнил после 18:00 | В сегодня-в-21:00-слепок поставщика не успевает, но в личном кабинете тут же «Возобновлено». В следующий вечерний cut-off — в заказ на послезавтра |
### §3.10 Шеринг с другими клиентами на том же источнике
**Формула** (живёт в `SupplierQuotaAllocator::computeOrder`, [код](../../../app/app/Services/Supplier/SupplierQuotaAllocator.php#L88-L98)):
```
order = max(самый_большой_лимит, ceil(сумма_лимитов ÷ 3))
```
Префлайт **не меняет формулу**, а **фильтрует входной массив `daily_limits`** — выкидывает клиентов, не прошедших проверку.
**Эффект зависит от того, кого выкинули:**
| Кто выкинут | `max(...)` | Заказ у поставщика | Маржа портала |
|---|---|---|---|
| Крупнейший клиент группы | Падает (новый крупнейший меньше) | **Уменьшается** — реальная экономия закупки | Падает |
| Любой некрупный | Не меняется | **Не меняется** | Падает на лимит выкинутого |
**Гарантии:**
1. **Никогда не вредит** другим клиентам в группе — их лимиты неприкосновенны.
2. **Никогда не заказывает у поставщика на «бедного»** — он исключён из формулы.
3. **Реальная экономия закупки** только при выкидывании крупнейшего. В остальных случаях защищаемся от логической ошибки «заказали лиды, оплатить которые некому» без эффекта на закупку.
### §3.11 Журналирование
При каждом срабатывании префлайт (заморозка / разморозка) — строка в новой таблице `balance_freeze_log`:
```sql
CREATE TABLE balance_freeze_log (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
event_type VARCHAR(20) NOT NULL, -- 'frozen' | 'unfrozen' | 'project_overload_dialog'
triggered_by VARCHAR(30) NOT NULL, -- 'cutoff_18msk' | 'project_update' | 'topup'
balance_rub_at_event DECIMAL(12,2) NOT NULL,
required_rub_at_event DECIMAL(12,2) NOT NULL,
leads_capacity INTEGER NOT NULL,
total_daily_limit INTEGER NOT NULL,
details JSONB, -- какие проекты, какая причина, и т.д.
created_at TIMESTAMP DEFAULT now()
);
```
RLS — `tenant_isolation` стандартный (как для большинства tenant-таблиц). Append-only через `audit_block_mutation` триггер. Цель — видеть в админке историю «когда и почему клиент попадал в заморозку».
---
## §4. Решение — часть 2: VTB-эквайринг (3 метода оплаты)
### §4.1 Архитектурный подход
**Интерфейс `TopupGatewayInterface`** + **три реализации**:
| Реализация | Метод оплаты | Статус в этом спеке |
|---|---|---|
| `BankTransferGateway` | Безнал (счёт PDF) | **Полная реализация** |
| `SbpGateway` | СБП | Архитектура + dev-заглушка; реальный код после Б-1 |
| `CardGateway` | Карта | Архитектура + dev-заглушка; реальный код после Б-1 |
`BillingTopupService` рефакторится — становится оркестратором:
```php
public function initiateTopup(
int $tenantId,
string $amountRub,
int $userId,
string $method // 'bank_transfer' | 'sbp' | 'card'
): TopupSession {
$gateway = $this->resolveGateway($method);
return $gateway->createSession($tenantId, $amountRub, $userId);
}
```
И отдельно обработка callback'а:
```php
public function confirmPayment(string $providerRef, ?int $adminUserId): BalanceTransaction {
// gateway-agnostic кредит баланса + запись audit
}
```
### §4.2 Состояния платежа
Новая таблица `topup_sessions`:
```sql
CREATE TABLE topup_sessions (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
user_id BIGINT REFERENCES users(id),
method VARCHAR(20) NOT NULL, -- 'bank_transfer' | 'sbp' | 'card'
amount_rub DECIMAL(12,2) NOT NULL,
provider_ref VARCHAR(100), -- номер счёта / VTB transaction ID / SBP QR ID
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- 'pending' | 'matched' | 'confirmed' | 'failed' | 'expired'
matched_at TIMESTAMP, -- когда нашли соответствие (только bank_transfer)
confirmed_at TIMESTAMP, -- когда человек подтвердил (или авто)
confirmed_by INTEGER REFERENCES users(id), -- кто подтвердил (NULL = автомат)
failed_reason VARCHAR(500),
metadata JSONB,
created_at TIMESTAMP DEFAULT now(),
updated_at TIMESTAMP DEFAULT now()
);
```
RLS — `tenant_isolation` для tenant_id; админ видит все через `crm_app_admin` роль.
После `status='confirmed'` — кредит баланса (`BalanceTransaction` с `type='topup'` и ссылкой на `topup_session_id`).
### §4.3 Безнал — полная реализация
**Шаг 1: Клиент инициирует пополнение**
В личном кабинете → Биллинг → «Пополнить счёт» → выбор метода «Безнал (счёт)» → ввод суммы → кнопка «Сформировать счёт».
Backend: `BankTransferGateway::createSession` — создаёт `topup_sessions(method='bank_transfer', status='pending')`, генерирует уникальный номер счёта (`SCH-{YYYY}-{tenant_id}-{seq}`).
**Шаг 2: Генерация PDF-счёта**
Шаблон `resources/views/pdf/invoice.blade.php`:
- Шапка: реквизиты Лидерры (название ООО, ИНН, КПП, юр. адрес, расчётный счёт VTB).
- Реквизиты плательщика (тенант): название, ИНН, КПП, юр. адрес — берутся из новых полей `tenants.legal_entity_*` (см. §4.7).
- Назначение платежа: «Оплата лидов по договору публичной оферты, счёт №SCH-2026-15-001 от 24.05.2026».
- Сумма: NNNN,NN ₽ (БЕЗ НДС или С НДС в зависимости от системы налогообложения Лидерры — `tax_regime` в admin-settings; для УСН 6% — без НДС).
- Срок оплаты: 5 рабочих дней.
Возвращается клиенту как файл скачивания. URL `/api/billing/topup-sessions/{id}/invoice.pdf`.
**Шаг 3: Авто-поиск платежа в VTB Бизнес API**
Артизан-команда `billing:vtb-statement-sync --since=N` (cron: каждые 15 минут после Б-1; в dev — manual только):
- Запрашивает выписку у VTB Бизнес API за период N часов.
- Для каждой входящей транзакции ищет в `назначение_платежа` номер счёта (`SCH-YYYY-...`).
- При совпадении — `topup_sessions(provider_ref=...).status = 'matched'`, `matched_at = now()`.
В dev/test (когда нет реальных VTB-реквизитов) — режим симуляции: команда `billing:vtb-statement-simulate {session_id} --amount=N` для ручного тестирования флоу.
**Шаг 4: Подтверждение человеком или автомат (admin setting)**
В админке `Биллинг → Настройки` — переключатель:
- **«С подтверждением» (по умолчанию):** платёж в статусе `matched` ждёт ручного клика «Подтвердить зачисление». До этого баланс не растёт.
- **«Автомат»:** платёж в статусе `matched` сразу переводится в `confirmed`, баланс растёт.
В админке `Биллинг → Ожидающие платежи` — список платежей в статусах `matched` и `pending`:
```
[SCH-2026-15-001] ООО Альфа, 100000₽, найден 24.05 15:42 [Подтвердить] [Отклонить]
[SCH-2026-22-003] ИП Иванов, 50000₽, не найден (3 дня) [Найти вручную] [Отменить]
```
Кнопка «Подтвердить» → `confirmPayment(...)` → кредит баланса + `BalanceTransaction(type='topup', topup_session_id=...)`.
**Шаг 5: Часовые email-алерты админу (только в режиме «С подтверждением»)**
Cron `billing:notify-pending-confirmations` каждый час:
- Если есть платежи в статусе `matched`, не подтверждённые — отправить email админу (`admin_emails` из settings, по умолчанию `eclips9363@gmail.com`).
- Throttle: один email в час суммарно.
### §4.4 СБП — архитектура + dev-заглушка
**Реализация в этом спеке:**
- `SbpGateway::createSession` — в dev/test возвращает фейковый QR-код PNG (`data:image/png;base64,...`) и через 5 секунд (фоновый job) переводит сессию в `confirmed`. Это позволяет полностью отлаживать UI и пост-обработку.
- В prod (после Б-1): зовёт реальный VTB SBP API, получает QR-код / платёжную ссылку, регистрирует callback на `/api/billing/vtb-sbp/callback`.
**Реализация после Б-1 (отдельная задача):**
- Боевая интеграция VTB SBP API (REST + signed callbacks).
- ОФД-Атол для 54-ФЗ чеков (см. §4.6).
- Боевые секреты — в YC Lockbox (SEC-5).
### §4.5 Карты — архитектура + dev-заглушка
**Реализация в этом спеке:**
- `CardGateway::createSession` — в dev/test возвращает редирект-URL на локальную страницу `/dev-mock-vtb-acquiring/{session_id}` с двумя кнопками «Симулировать успех» / «Симулировать ошибку». Клик → переводит сессию в `confirmed` или `failed`.
- В prod (после Б-1): редирект на боевую страницу VTB internet-эквайринга с 3DS Secure.
**Реализация после Б-1 (отдельная задача):**
- Боевая интеграция VTB Acquiring API.
- 3DS Secure (обязательно для всех карт).
- Обработка chargeback'ов.
- ОФД-Атол для 54-ФЗ чеков.
### §4.6 54-ФЗ фискализация
**Текущее состояние:** не реализовано. Раздел подсветить в архитектуре, реальный код — после Б-1 параллельно с СБП.
**Когда нужен чек:**
| Метод оплаты | Чек 54-ФЗ |
|---|---|
| Безнал (юр→юр) | **Не нужен** (статья 1.2 п.9 закона) |
| СБП | **Обязателен** (B2C ритейл-платёж) |
| Карта | **Обязателен** (B2C ритейл-платёж) |
**Архитектура** (заглушка в этом спеке):
```php
interface FiscalReceiptProvider {
public function issueReceipt(TopupSession $session): FiscalReceipt;
}
```
Реализации:
- `AtolOnlineFiscalProvider` — реальная интеграция (после Б-1).
- `NoOpFiscalProvider` — для безнала (возвращает «чек не требуется по 54-ФЗ»).
- `DevMockFiscalProvider` — для dev/test (фейковый чек ID).
### §4.7 Реквизиты тенанта
Новые поля в `tenants` (для счёта-фактуры):
```sql
ALTER TABLE tenants ADD COLUMN legal_entity_name VARCHAR(255); -- "ООО Альфа" или "ИП Иванов Иван"
ALTER TABLE tenants ADD COLUMN legal_entity_inn VARCHAR(12);
ALTER TABLE tenants ADD COLUMN legal_entity_kpp VARCHAR(9); -- NULL для ИП
ALTER TABLE tenants ADD COLUMN legal_entity_address TEXT;
ALTER TABLE tenants ADD COLUMN legal_entity_form VARCHAR(20); -- 'OOO' | 'IP' | 'OAO' | 'ZAO' | 'OTHER'
```
В личном кабинете клиента → Настройки → «Реквизиты юр. лица» — форма для заполнения. **Обязательны** для безнала (без них счёт PDF не выписывается); для СБП/карт — опционально (но желательно).
Валидация ИНН (контрольное число), КПП (формат), форма юрлица — стандартными правилами laravel.
### §4.8 Минимум/максимум суммы пополнения
| Метод | Минимум | Максимум |
|---|---|---|
| Безнал | 1000 ₽ | 1 000 000 ₽ |
| СБП | 100 ₽ | 600 000 ₽ (лимит СБП) |
| Карта | 100 ₽ | 100 000 ₽ за одну транзакцию |
Конфигурация в `config/billing.php` (settable).
---
## §5. Архитектура изменений
### §5.1 Карта изменений по слоям
| Слой | Что |
|---|---|
| **БД** | `tenants` (+`frozen_by_balance_at`, +5 `legal_entity_*` полей); новые таблицы `balance_freeze_log`, `topup_sessions` |
| **Бэк-сервисы** | `SupplierQuotaAllocator` (новый pre-filter pipeline); `BalancePreflightService` (новый, проверка платёжеспособности); `BillingTopupService` (рефакторинг под gateway pattern); `TopupGatewayInterface` + 3 реализации; `FiscalReceiptProvider` + 3 реализации |
| **Бэк-джобы** | `BalancePreflightSweepJob` (cron @18:00 MSK ежедневно); `BalanceFrozenNotificationJob` (event-driven при заморозке); `VtbStatementSyncJob` (cron @ каждые 15 мин); `NotifyPendingConfirmationsJob` (cron hourly) |
| **Бэк-команды** | `billing:preflight-sweep`, `billing:vtb-statement-sync`, `billing:notify-pending-confirmations`, `billing:preflight-initial-sweep` (one-time migration), `billing:vtb-statement-simulate` (dev only) |
| **Бэк-контроллеры** | `BillingController` (+endpoints: `/api/billing/topup/initiate`, `/api/billing/topup-sessions/{id}`, `/api/billing/topup-sessions/{id}/invoice.pdf`); `ProjectController` (preflight check + 409 response с диалогом); `Admin/PendingTopupsController` (новый); `Admin/BillingSettingsController` (новый, для переключателя auto/manual) |
| **Бэк-Mail** | `BalanceFrozenMail`, `BalanceFrozenReminderMail`, `BalanceFrozenFinalMail`, `BalanceUnfrozenMail`, `PendingConfirmationsAdminMail` |
| **Фронт-компоненты** | `BalanceFrozenBanner.vue`, `BalanceCapacityIndicator.vue`, `ProjectLimitOverloadDialog.vue`, `TopupMethodPicker.vue`, `BankTransferInvoiceView.vue`, `SbpQrCodeView.vue`, `CardRedirectView.vue`, `PendingPaymentsAdminView.vue`, `BillingSettingsAdminView.vue`, `LegalEntityForm.vue` (в Настройки) |
| **Фронт-views** | `TopupView.vue` (новый, обёртка с переключателем methods); `BillingFrozenInfoView.vue` |
| **Pinia** | `billingStore` (расширение под topup-сессии); `tenantStore` (frozen-флаг) |
### §5.2 Sequence-диаграмма префлайт на cut-off
```
18:00 MSK Cron
├── BalancePreflightSweepJob::handle()
│ │
│ ├── для каждого tenant:
│ │ │
│ │ ├── BalancePreflightService::evaluate(tenant)
│ │ │ │
│ │ │ ├── читает projects (active=true, eligible_tomorrow=true)
│ │ │ ├── требуемые лиды = Σ daily_limit
│ │ │ ├── ёмкость лидов = BalanceToLeadsConverter::convert(balance, delivered, tiers)['leads']
│ │ │ ├── сравнивает: passes = capacity >= required
│ │ │ └── возвращает PreflightResult { passes: bool, required_leads, capacity_leads, deficit_leads }
│ │ │
│ │ ├── если passes изменился:
│ │ │ │
│ │ │ ├── tenant.frozen_by_balance_at = NULL | now()
│ │ │ ├── balance_freeze_log.insert(event_type='frozen' | 'unfrozen')
│ │ │ ├── dispatch(BalanceFrozenMail | BalanceUnfrozenMail)
│ │ │ │
│ │ │ └── (если frozen) для каждого projects:
│ │ │ projects.preflight_blocked_at = now() (новая колонка)
│ │ │
│ │ └── (если unfrozen) projects.preflight_blocked_at = NULL для всех
│ │
│ └── для следующего tenant...
18:05 MSK (после префлайт) — обычный SyncSupplierProjectsJob запускается
├── SupplierQuotaAllocator::allocate(eligible_projects)
│ │
│ ├── фильтр eligible_projects: only WHERE preflight_blocked_at IS NULL
│ ├── computeOrder([daily_limit, ...]) (формула не меняется)
│ └── distributeForPlatform(order, [B1, B2, B3])
└── SyncSupplierProjectJob (per project) → отправка лимитов поставщику
```
### §5.3 Sequence-диаграмма пополнения (Безнал)
```
Клиент в личном кабинете
├── Кнопка "Пополнить счёт"
└── TopupMethodPicker → выбор "Безнал (счёт)"
└── Ввод суммы → "Сформировать счёт"
└── POST /api/billing/topup/initiate { method: 'bank_transfer', amount_rub: '100000.00' }
├── BillingTopupService::initiateTopup(...)
│ │
│ └── BankTransferGateway::createSession(...)
│ │
│ ├── создание topup_sessions(status='pending', provider_ref='SCH-2026-15-001')
│ └── возвращает { session_id, invoice_url: '/api/billing/topup-sessions/15001/invoice.pdf' }
└── Редирект клиента на invoice_url → скачивание PDF
Клиент оплачивает в своём банк-клиенте
└── Деньги поступают на расчётный счёт VTB Лидерры (за ~часы)
VtbStatementSyncJob (каждые 15 минут)
├── VTB Business API → выписка
└── для каждой входящей транзакции:
├── парсинг назначения платежа → ищем SCH-YYYY-tenant-seq
└── если совпадение найдено + сумма совпадает:
├── topup_sessions.status = 'matched'
├── matched_at = now()
└── (если admin-setting auto_confirm=true):
├── BillingTopupService::confirmPayment(provider_ref, NULL)
│ │
│ ├── topup_sessions.status = 'confirmed'
│ ├── tenant.balance_rub += amount_rub (bcadd)
│ └── BalanceTransaction.create(type='topup', topup_session_id=...)
└── (если auto_confirm=false): ждёт ручного подтверждения
NotifyPendingConfirmationsJob (каждый час)
└── если есть topup_sessions(status='matched') не подтверждённые:
└── PendingConfirmationsAdminMail → admin email
Админ в кабинете
└── Биллинг → Ожидающие платежи → [Подтвердить SCH-2026-15-001]
└── POST /api/admin/topup-sessions/15001/confirm
└── BillingTopupService::confirmPayment(provider_ref, admin_user_id)
├── topup_sessions.status = 'confirmed'
├── tenant.balance_rub += amount_rub
├── BalanceTransaction.create(type='topup', confirmed_by=admin_user_id)
└── BalancePreflightService::evaluate(tenant) ← может разморозить!
└── если frozen_by_balance_at был NOT NULL и теперь passes:
├── tenant.frozen_by_balance_at = NULL
├── balance_freeze_log.insert(event_type='unfrozen', triggered_by='topup')
└── BalanceUnfrozenMail отправляется
```
### §5.4 Изменения в `SupplierQuotaAllocator`
Минимальное и неинвазивное — добавление одного шага в caller (`SyncSupplierProjectsJob`):
**До:**
```php
$eligibleProjects = $this->projectsQuery->getEligibleForToday($targetDate);
$dto = SupplierQuotaAllocator::allocate(..., $eligibleProjects, $targetDate);
```
**После:**
```php
$eligibleProjects = $this->projectsQuery->getEligibleForToday($targetDate);
// NEW: фильтр по frozen-флагу tenant
$eligibleProjects = $eligibleProjects->reject(
fn($p) => $p->tenant->frozen_by_balance_at !== null
);
$dto = SupplierQuotaAllocator::allocate(..., $eligibleProjects, $targetDate);
```
`SupplierQuotaAllocator` сам не меняется — он pure-функция, принимает то что дали. Это важно для тестируемости (формула не сломалась).
---
## §6. Сценарии (end-to-end)
### §6.1 Префлайт — пассивный износ
**Воскресенье 00:00.** Клиент с балансом 1000₽ = 30 лидов (tier 3, цена ~33₽/лид). Проекты заказывают 25/день. Запас 5.
**Воскресенье 18:00.** Cron `BalancePreflightSweepJob`:
- `BalancePreflightService::evaluate(client)``passes=true` (хватает на 25).
- `frozen_by_balance_at` остаётся `NULL`.
**Воскресенье 18:05.** `SyncSupplierProjectsJob` — все 25 идут в заказ поставщику. Поставщик в 21:00 берёт слепок.
**Понедельник.** В течение дня партии лидов приходят, клиент получает все 25, баланс падает до 0. Никаких внутридневных стопов.
**Понедельник 18:00.** Cron snova:
- `evaluate(client)``passes=false` (0₽ ≠ 25 лидов).
- `frozen_by_balance_at = now()`.
- `balance_freeze_log.insert(event='frozen', triggered_by='cutoff_18msk')`.
- `BalanceFrozenMail` отправляется.
**Понедельник 18:05.** `SyncSupplierProjectsJob` — этот клиент исключён, формула пересчитывается для остальных в группе на источнике.
**Понедельник 19:00.** Клиент видит письмо, пополняет на 1000₽ через безнал PDF → 1-2 дня ждёт зачисления через сверку. Или через СБП/карту (после Б-1) — мгновенно.
**Вторник 14:00.** Безнал подтверждён админом (или авто) → баланс 1000₽. `confirmPayment` зовёт `evaluate``passes=true`. `frozen_by_balance_at = NULL`. `BalanceUnfrozenMail`. В личном кабинете баннер исчезает.
**Вторник 18:00.** Cron snova — клиент в заказе на среду. Со среды получает лиды.
### §6.2 Префлайт — активная нехватка при создании проекта
**Клиент с балансом 1000₽ = 30 лидов.** Имеет 3 проекта по 10 = 30 лимит. Всё впритык.
**Клиент создаёт 4-й проект с лимитом 20.** Бэк (`ProjectController::store`) делает превью-префлайт:
- `Σ daily_limit (после сохранения) = 50` лидов
- `capacity = BalanceToLeadsConverter::convert(1000₽, delivered, tiers)['leads'] = 30` лидов
- `30 < 50` → недостаток 20 лидов
Возвращает HTTP 409 с body:
```json
{
"error": "balance_insufficient",
"current_balance_rub": "1000.00",
"current_capacity_leads": 30,
"would_be_required_leads": 50,
"deficit_leads": 20
}
```
Фронт показывает диалог `ProjectLimitOverloadDialog.vue`:
```
Этот лимит превышает баланс.
У тебя 1000₽ = 30 лидов по текущему тарифу.
После сохранения нужно 50 лидов.
Не хватает: 20 лидов.
Чтобы он начал работать, нужно одно из:
• Пополнить счёт (примерно 700₽ покроют 20 лидов недостачи)
• Поставить лимит этого проекта 0
• Уменьшить лимиты других проектов
[Сохранить и приостановить этот] [Поставить лимит 0] [Отмена]
```
«Примерно 700₽» — оценка через обратное преобразование: добавляем баланс шагами по 100₽ и смотрим, при какой сумме `capacity` вырастет до 50 (фронт делает это локально, не дёргая бэк).
- Если «Сохранить и приостановить» → POST `/api/projects` с флагом `force_save_blocked=true`. Создаётся проект с `preflight_blocked_at = now()`. Остальные 3 проекта работают.
- Если «Поставить лимит 0» → POST `/api/projects` с `daily_limit = 0`. Создаётся, не идёт в заказ.
- Если «Отмена» → форма закрывается, ничего не сохраняется.
### §6.3 VTB — Безнал end-to-end (после Б-1)
1. Клиент → Биллинг → «Пополнить 100000₽» → выбор «Безнал».
2. Backend создаёт `topup_sessions(status='pending', provider_ref='SCH-2026-15-001')`.
3. Скачивает PDF-счёт с реквизитами Лидерры (ИНН, КПП, р/с) и своими реквизитами.
4. Идёт в свой банк-клиент, оплачивает.
5. Деньги в течение нескольких часов приходят на р/с Лидерры в VTB.
6. `VtbStatementSyncJob` за 15 минут после прихода находит транзакцию с «SCH-2026-15-001» в назначении, ставит `status='matched'`.
7. Часовой `NotifyPendingConfirmationsJob` шлёт админу email «есть 1 непровер. платёж».
8. Админ в админке → Биллинг → Ожидающие платежи → видит запись → «Подтвердить» → 100000₽ кредитуется, `BalanceTransaction` пишется.
9. Если клиент был в `frozen_by_balance_at``evaluate` пересчитывает → разморозка → `BalanceUnfrozenMail`.
В dev-режиме шаг 6 заменяется на `billing:vtb-statement-simulate 15001 --amount=100000` (manual).
---
## §7. Известные открытые вопросы
1. **Учёт перехода ступени за день.** `BalanceToLeadsConverter::convert` уже учитывает «текущую ступень и переход на следующие при росте объёма». Граничный случай «дневной заказ переваливает за порог ступени, после чего цена меняется» автоматически обрабатывается — `convert` итерирует по ступеням и считает разные цены для разных кусков. **Не нужна дополнительная логика.**
2. **Что если клиент имеет несколько менеджеров с email?** Письмо `BalanceFrozenMail` — на главный email тенанта (`tenant.email`) или на всех users этого тенанта? **Решение по умолчанию:** на главный email тенанта (как сейчас в `ZeroBalancePausedMail`).
3. **Формат номера счёта** `SCH-YYYY-{tenant_id}-{seq}` — гарантирована уникальность через `(tenant_id, seq)` сериал. Если переходим на ОФД-Атол, может потребоваться другой формат. **Решение:** сейчас наш формат, при подключении ОФД — модернизируем (отдельная задача).
4. **Локализация PDF-счёта** — пока только русский. После Б-1 при необходимости — английский / казахский (если расширим географию).
5. **Reconcilation между `topup_sessions` и `balance_transactions`** — есть ли инвариант «каждая completed-сессия = одна balance_transaction»? Да, должен быть — добавить foreign key + unique constraint.
---
## §8. Future enhancements (Спек D и далее)
1. **«Отдать разморозившемуся клиенту лиды, уже купленные сегодня, через шеринг»** (см. §3.10) — бизнес: разморозившийся в 15:00 клиент мог бы получить лиды, поступающие после 15:00 по его источнику, если есть свободные слоты шеринга. Технически — модификация `RouteSupplierLeadJob` + расширение eligible-кандидатов после mid-day events. Реальная частота сценария = низкая; делать после месяца сбора статистики.
2. **«Приоритет шеринга 4+ клиентов — те, у кого хватало баланса на момент cut-off»** — снимок «утренней платёжеспособности» сохраняется, при шеринге сверяется. Полезно при переполненном шеринге (>3 покупателей конкурируют за лид).
3. **Авто-сверка с VTB Бизнес API** — реальная интеграция (не заглушка), боевой `VtbStatementSyncJob` с retry и подписью.
4. **Recurring-платежи** — автосписание с карты раз в месяц (отдельная фича, требует UX-проработки).
5. **Возвраты** (refund) — если бизнес-нужда подтвердится. Сейчас Спек A явно говорит «не делаем».
6. **Мульти-валютность** — если выходим на казахский / белорусский рынок (KZT/BYN).
---
## §9. Связи
- [Спек A](2026-05-23-billing-v2-spec-a-balance-rub-design.md) — единый ₽-баланс, `BalanceToLeadsConverter` (используется в этом спеке).
- [Спек B](2026-05-23-billing-v2-spec-b-duplicates-design.md) — `supplier_lead_deliveries` lock-таблица, шеринг до 3 клиентов на лид (контекст для §3.10).
- [App allocator](../../../app/app/Services/Supplier/SupplierQuotaAllocator.php) — формула заказа, остаётся без изменений.
- [App ledger](../../../app/app/Services/Billing/LedgerService.php) — списания с баланса, остаётся без изменений.
- [App topup](../../../app/app/Services/Billing/BillingTopupService.php) — рефакторится в этом спеке.
- [App webhook routing](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php) — добавляется фильтр frozen-проектов.
- [App project controller](../../../app/app/Http/Controllers/Api/ProjectController.php) — добавляется preflight check.
- [Pravila §13.2](../../../docs/Pravila_raboty_Claude_v1_1.md) — финансовая нормативка.
- [CLAUDE.md §6](../../../CLAUDE.md) — текущая фаза.
- [project_billing_v2 memory](../../../../C:/Users/Administrator/.claude/projects/c---------------------crm-------------/memory/project_billing_v2.md) — серия из 3 спеков.
- [SEC-3, SEC-5 (memory)](../../../../C:/Users/Administrator/.claude/projects/c---------------------crm-------------/memory/project_server_hardening.md) — блокеры YC Lockbox для секретов VTB.
---
## §10. План реализации (overview)
Детальный план — отдельным документом через `superpowers:writing-plans` после утверждения этого спека.
Предварительная разбивка на фазы (для оценки масштаба):
**Phase 1 — Префлайт баланса** (~5-7 задач):
- Миграция БД (`frozen_by_balance_at`, `preflight_blocked_at`, `balance_freeze_log`).
- `BalancePreflightService` (pure) + тесты.
- `BalancePreflightSweepJob` + cron.
- 4 Mailable + throttle.
- `BalanceFrozenBanner.vue` + `BalanceCapacityIndicator.vue`.
- `ProjectController` preflight check + 409 response.
- `ProjectLimitOverloadDialog.vue`.
- Фильтр в `SyncSupplierProjectsJob`.
- One-time `billing:preflight-initial-sweep`.
**Phase 2 — Безнал PDF + админка** (~6-8 задач):
- Миграция БД (`legal_entity_*` в `tenants`, `topup_sessions`).
- `TopupGatewayInterface` + `BankTransferGateway`.
- PDF-генерация (`resources/views/pdf/invoice.blade.php`).
- `LegalEntityForm.vue` + валидация.
- `TopupView.vue` + `TopupMethodPicker.vue` + `BankTransferInvoiceView.vue`.
- `Admin/PendingTopupsController` + `PendingPaymentsAdminView.vue`.
- `Admin/BillingSettingsController` (auto/manual переключатель).
- `VtbStatementSyncJob` (с dev-симулятором).
- `NotifyPendingConfirmationsJob`.
**Phase 3 — СБП и Карты dev-заглушки** (~4-5 задач):
- `SbpGateway` (dev режим) + `SbpQrCodeView.vue`.
- `CardGateway` (dev режим) + `/dev-mock-vtb-acquiring/...` страница.
- `FiscalReceiptProvider` interface + `NoOpFiscalProvider` + `DevMockFiscalProvider`.
**Phase 4 — Тесты + smoke** (~3-4 задач):
- Pest end-to-end сценарии префлайт (frozen/unfrozen flow).
- Pest end-to-end сценарии безнала (создание счёта → симуляция выписки → подтверждение).
- Vitest на новые Vue-компоненты.
- Регрессия (Pest --parallel + Vitest + lychee + gitleaks).
**Phase 5 — Документация + memory + ПИЛОТ.md** (~2 задач):
- Обновление `project_billing_v2.md` (Спек C статус, известные хвосты).
- ADR (если требуется новый — `docs/adr/016-preflight-vtb-architecture.md`).
- Обновление `ПИЛОТ.md` после выкатки на прод.
**Out of scope этого плана (post-Б-1, отдельные планы):**
- Боевая интеграция VTB Acquiring (карты).
- Боевая интеграция VTB SBP API.
- Боевая интеграция VTB Бизнес API (авто-сверка).
- Боевая интеграция ОФД-Атол (фискализация).
- Боевые секреты в YC Lockbox.
---
**Конец Спека C.**
File diff suppressed because it is too large Load Diff
+26 -4
View File
@@ -198,10 +198,24 @@ export function shouldEscalate(regexResult) {
return true;
}
export async function callAnthropicAPI(prompt, { apiKey, model = 'claude-haiku-4-5-20251001', fetchImpl = fetch }) {
const r = await fetchImpl('https://api.anthropic.com/v1/messages', {
// LLM Layer 2 ходит через реселлера ProxyAPI (официальный api.anthropic.com
// недоступен из РФ). Базовый URL переопределяется ROUTER_LLM_BASE_URL — на
// случай смены реселлера или возврата на официальный эндпоинт.
const DEFAULT_LLM_BASE_URL = 'https://api.proxyapi.ru/anthropic';
export async function callAnthropicAPI(prompt, {
apiKey,
baseUrl = DEFAULT_LLM_BASE_URL,
model = 'claude-haiku-4-5',
fetchImpl = fetch,
}) {
const url = `${String(baseUrl).replace(/\/+$/, '')}/v1/messages`;
const r = await fetchImpl(url, {
method: 'POST',
headers: {
// ProxyAPI ждёт Bearer, официальный API — x-api-key. Шлём оба:
// каждый эндпоинт берёт нужный заголовок и игнорирует чужой.
'authorization': `Bearer ${apiKey}`,
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
@@ -213,7 +227,7 @@ export async function callAnthropicAPI(prompt, { apiKey, model = 'claude-haiku-4
}),
});
if (!r.ok) {
throw new Error(`Anthropic API ${r.status}: ${await r.text()}`);
throw new Error(`Router LLM ${r.status}: ${await r.text()}`);
}
const data = await r.json();
return data.content?.[0]?.text || '';
@@ -239,8 +253,16 @@ export async function classify(prompt, registry, options = {}) {
}
const llmCall = options.llmCall || (async () => {
// Ключ берём из ОТДЕЛЬНОЙ переменной ROUTER_LLM_KEY, НЕ из ANTHROPIC_API_KEY:
// иначе ключ перехватит сам Claude Code и уведёт основную сессию с подписки
// на платный API. Нет ключа → Layer 2 выключен, тихо остаёмся на regex.
const apiKey = process.env.ROUTER_LLM_KEY;
if (!apiKey) return null;
const llmPrompt = buildLLMPrompt(prompt, registry);
const text = await callAnthropicAPI(llmPrompt, { apiKey: process.env.ANTHROPIC_API_KEY });
const text = await callAnthropicAPI(llmPrompt, {
apiKey,
baseUrl: process.env.ROUTER_LLM_BASE_URL || undefined,
});
return parseLLMResponse(text);
});
+59 -1
View File
@@ -104,7 +104,7 @@ describe('classifyByRegex — confidence', () => {
});
});
import { buildLLMPrompt, parseLLMResponse, shouldEscalate, classify } from './router-classifier.mjs';
import { buildLLMPrompt, parseLLMResponse, shouldEscalate, classify, callAnthropicAPI } from './router-classifier.mjs';
describe('buildLLMPrompt', () => {
it('serializes active nodes with id+name+top-3 triggers', () => {
@@ -178,3 +178,61 @@ describe('classify — full integration (with mock LLM)', () => {
expect(calls).toBe(1); // Second hit cache.
});
});
describe('callAnthropicAPI — ProxyAPI wiring', () => {
it('posts to ProxyAPI base by default with Bearer auth', async () => {
let captured;
const fetchImpl = async (url, opts) => {
captured = { url, opts };
return { ok: true, json: async () => ({ content: [{ text: '{"taskType":"question"}' }] }) };
};
const text = await callAnthropicAPI('hi', { apiKey: 'sk-test', fetchImpl });
expect(captured.url).toBe('https://api.proxyapi.ru/anthropic/v1/messages');
expect(captured.opts.headers.authorization).toBe('Bearer sk-test');
expect(text).toContain('question');
});
it('honors a custom baseUrl and strips trailing slash', async () => {
let capturedUrl;
const fetchImpl = async (url) => {
capturedUrl = url;
return { ok: true, json: async () => ({ content: [{ text: 'x' }] }) };
};
await callAnthropicAPI('hi', { apiKey: 'k', baseUrl: 'https://example.test/', fetchImpl });
expect(capturedUrl).toBe('https://example.test/v1/messages');
});
it('throws on non-ok response', async () => {
const fetchImpl = async () => ({ ok: false, status: 401, text: async () => 'Invalid API Key' });
await expect(callAnthropicAPI('hi', { apiKey: 'bad', fetchImpl })).rejects.toThrow(/401/);
});
});
describe('classify — isolation from Claude Code auth', () => {
it('skips LLM and falls back to regex when ROUTER_LLM_KEY is absent', async () => {
const saved = process.env.ROUTER_LLM_KEY;
delete process.env.ROUTER_LLM_KEY;
try {
const r = await classify('что-то совсем непонятное', fakeRegistry);
expect(r.source).toBe('regex');
} finally {
if (saved !== undefined) process.env.ROUTER_LLM_KEY = saved;
}
});
it('does NOT read ANTHROPIC_API_KEY (would hijack the main session)', async () => {
const savedRouter = process.env.ROUTER_LLM_KEY;
const savedAnthropic = process.env.ANTHROPIC_API_KEY;
delete process.env.ROUTER_LLM_KEY;
process.env.ANTHROPIC_API_KEY = 'sk-should-not-be-used';
try {
const r = await classify('что-то совсем непонятное', fakeRegistry);
// No ROUTER_LLM_KEY → must stay on regex even though ANTHROPIC_API_KEY is set.
expect(r.source).toBe('regex');
} finally {
if (savedRouter !== undefined) process.env.ROUTER_LLM_KEY = savedRouter;
if (savedAnthropic !== undefined) process.env.ANTHROPIC_API_KEY = savedAnthropic;
else delete process.env.ANTHROPIC_API_KEY;
}
});
});
+9 -1
View File
File diff suppressed because one or more lines are too long