Инициализировать репозиторий «Дейл»

Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
Rustam Khalimov
2026-09-11 02:50:17 +03:00
commit 9e07568ddd
1402 changed files with 177470 additions and 0 deletions
@@ -0,0 +1,58 @@
# SDD ledger — plan: docs/superpowers/plans/2026-09-05-deal-stage4-pipeline.md
Проект НЕ git: фиксация — отчёты задач и этот ledger. Ревью — по фактическим файлам.
## Todos
- Task 1: complete (review clean; миграция TenantPipeline применена, 410 PASS). Отчёт: task-1-report.md.
- [x] Task 1: Миграция TenantPipeline
- Task 2: complete (review clean; 410 PASS; модуль чист, цикла нет). Отчёт: task-2-report.md.
- [x] Task 2: Модуль Pipeline — DTO, порт IPipelineStore, реестр
- Task 3: complete (review clean; build 0/0, 410 PASS; харнесс + psql зелёные; Kanban→Pipeline цикла нет). Отчёт: task-3-report.md.
- Task 3: complete (review clean; 410 PASS; чистка dedup в KanbanStore в транзакции). Отчёт: task-3-report.md.
- [x] Task 3: PipelineStore (EF) + чистка dedup при удалении карточки
- Task 4: complete (review clean; build 0/0, 454 PASS; ядро чистое — без EF/HTTP, циклов нет; quirks python 1:1). Отчёт: task-4-report.md.
- Task 4: complete (review clean; 454 PASS; ядро разбора 1:1, SHA1 как в прототипе). Отчёт: task-4-report.md.
- [ ] Task 4: Чистое ядро разбора
- Task 5: complete (review clean; build 0/0, 483 PASS; сервисы приёма/обработки 1:1, цикла нет). Отчёт: task-5-report.md.
- Task 5: complete (review clean; 483 PASS; return/force/unlearn 1:1). Отчёт: task-5-report.md.
- [x] Task 5: Ingest + ProcessingService
- Task 6: complete (review clean; 489 PASS; LocalAiClassifier детерминирован). Отчёт: task-6-report.md.
- [x] Task 6: Порт IAiClassifier + LocalAiClassifier
- Task 7: complete (build 0/0, 503 PASS; CardComposer + PipelineCardWriter через IKanjStore.AddCardAsync, карточка-линк dedup 1:1). Отчёт: task-7-report.md.
- Task 7: complete (review clean; 503 PASS; карточка 1:1 с _store_lead). Отчёт: task-7-report.md.
- [x] Task 7: CardComposer/PipelineCardWriter
- Task 8: complete (build 0/0, 521 PASS; pump 1:1 с _pump_unlocked, AiLeadMapper единый для воркера/LocalAiClassifier; ревью-фикс стемп is_vacancy_known на ИИ-пути L11081111). Отчёт: task-8-report.md.
- Task 8: complete (1 fix round; ревью нашло пропуск стэмпа is_vacancy_known на AI-пути → исправлено + позитивные тесты; 521 PASS). Отчёт: task-8-report.md.
- [x] Task 8: PipelineWorkerService (pump 1:1)
- Task 9: complete (build 0/0, 521 PASS; curl 22/22: /pipeline/stats|queue|rejected + return/clear/delete формы, demo/ingest гвард dialog+msgId; DI Program.cs). Отчёт: task-9-report.md.
- Task 9: complete (review clean; 521 PASS; curl 22/22). Отчёт: task-9-report.md.
- [x] Task 9: Эндпоинты pipeline
- Task 10: complete (build 0/0, 524 PASS; curl 18/18: admin/tick реальный storage+pipeline+queue, purge отсева в тике purgedRejected, fts/rebuild ok/ready; тост «Отсев очищен» в публикаторе + оркестратор тика в Api). Отчёт: task-10-report.md.
- Task 10: complete (review clean; 524 PASS; curl 18/18). Отчёт: task-10-report.md. Note для T11: общий PipelinePumpGate между admin/tick и фоновым циклом.
- [x] Task 10: admin/tick + fts/rebuild + SSE-тост
- Task 11: complete (build 0/0, 534 PASS; curl 17/17: фон 2 с — карточка/отсев без tick, purge отсева 30 с — тост + rejected 0; PipelinePumpGate общий, purge в StorageTickScheduler). Отчёт: task-11-report.md.
- Task 11: complete (review clean; 534 PASS; curl 17/17). Отчёт: task-11-report.md.
- [x] Task 11: Фоновые циклы (pump 2 с + purge)
- Task 12: complete (build 0/0, 535 PASS; curl 22/22: /api/search FTS — q=python релевантная первой (title > source), q=работа по tsvector-морфологии «работой» (LIKE не мог), q=go по title, q<2 пусто, messages:[] , logout 401; поиск — KanbanStore.SearchCardsAsync raw SQL SearchTsv@@plainto_tsquery + LIKE, CardsService делегирует порту). Отчёт: task-12-report.md.
- Task 12: complete (review clean; 535 PASS; curl 22/22). Отчёт: task-12-report.md.
- [x] Task 12: FTS-поиск карточек /api/search
- Task 13: complete (review pending; build 0/0, 535 PASS; curl 74/74 — сквозной сценарий на реальных записях). Отчёт: task-13-report.md.
- Task 13: complete (review clean; сквозная приёмка 74/74, повторяема). Отчёт: task-13-report.md.
- **Этап 4 завершён**: финальное whole-scope ревью ✅ (build 0/0, 535 PASS, путь сообщения 1:1, dev-БД чиста, docs/roadmap актуальны).
- [x] Task 13: Финал/сквозная приёмка
## Pre-flight scan (краткий)
| Пара | Производит/потребляет | Результат |
|---|---|---|
| T1 → T3 | миграция → EF-адаптер | Чисто |
| T2 → T3/T5 | DTO/порт → адаптер/сервис | Чисто |
| T3 → T3 | KanbanStore чистит DedupEntries при удалении карточки | Чисто (реализовано по Ruling 3: KanbanStore удаляет `DedupEntries WHERE LeadId=?` напрямую своим TenantDbContext в транзакции DeleteForever/Purge/ClearCol — без интерфейсов/порта Pipeline; Kanban про Pipeline не знает, цикла нет) |
| T4 → T6/T8 | ядро разбора → LocalAiClassifier/worker | Чисто |
| T5 → T9 | ProcessingService → эндпоинты | Чисто |
| T6/T7 → T8 | классификатор/писатель → worker | Чисто |
| T8 → T11 | worker → фоновый цикл | Чисто |
| T10 | tick реальный pipeline/queue | Чисто |
| T12 | /api/search апгрейд (FTS) | Kanban-эндпоинт правится — учесть |
| T7 | Pipeline пишет карточку через IKanjStore.AddCardAsync | Pipeline→Kanban (порт) — разрешено; цикла нет |
## Task status
@@ -0,0 +1,55 @@
# Task 1 — «Миграция TenantPipeline: QueueItems/RejectedItems/DedupEntries + FTS-колонки» — отчёт
Статус: **DONE** (build 0/0, тесты 410/410 PASS, миграция применена к dev-схеме дефолтного тенанта, psql-приёмка зелёная).
## Файлы
### Созданы — сущности (`src/core/Deal.Infrastructure/Persistence/Entities/`, 1 тип = 1 файл)
| Файл | Таблица | Ключевые поля (Ruling 1(а)) |
|---|---|---|
| `QueueItemEntity.cs` | `QueueItems` (= pipeline_msg) | Id (text PK, `p_`), DialogId, ChannelName/ChannelHandle/ChannelHue (`#666`), Text (text; ≤6000 — режет сервис), MsgId (bigint?), MsgAt (timestamptz), Status (`new`), Force (bool), CreatedAt, UpdatedAt |
| `RejectedItemEntity.cs` | `RejectedItems` (= rejected_msgs) | Id (text PK, `r_`; детерминированный `r_<dialog>_<msgId>` либо `r_`+hex), DialogId, MsgId (bigint?), ChannelName/ChannelHandle/ChannelHue, Text (text), Stage, Reason (text; ≤500), Kw (text; ≤200), Source (`stop`), MsgAt, RejectedAt, Returned (bool), ReturnedAt (timestamptz?), ReturnReason (≤500), SearchTsv (tsvector) |
| `DedupEntryEntity.cs` | `DedupEntries` (= dedup) | Hash (text PK, без префикса), LeadId (text?, БЕЗ FK — «мягкая» ссылка, чистка Ruling 3), CreatedAt |
Времена — `DateTimeOffset``timestamptz`. Nullable-поля — только по Ruling 1: `MsgId` (bigint?) у Queue/Rejected, `ReturnedAt` у отсева; `RejectedItems.MsgAt` — NOT NULL (план пометил nullable только MsgId/ReturnedAt). `SearchTsv``NpgsqlTsVector` (инициализатор `NpgsqlTsVector.Empty`).
### Созданы — конфигурации (`src/core/Deal.Infrastructure/Persistence/`)
| Файл | Содержание |
|---|---|
| `QueueItemConfiguration.cs` | `ToTable("QueueItems")`, HasKey(Id), `Text .HasColumnType("text")`, индекс `IX_QueueItems_Status_CreatedAt` (Status, CreatedAt) |
| `RejectedItemConfiguration.cs` | `ToTable("RejectedItems")`, HasKey(Id), `Text/Reason/Kw` — text, `SearchTsv` = `to_tsvector('russian', coalesce("Text",''))` STORED (Ruling 6, `_FTS_TARGETS`), индекс `IX_RejectedItems_RejectedAt`, GIN `IX_RejectedItems_SearchTsv` |
| `DedupEntryConfiguration.cs` | `ToTable("DedupEntries")`, HasKey(Hash); LeadId без FK |
### Изменены
- `Entities/CardEntity.cs` — свойство `SearchTsv` (`NpgsqlTsVector`, перед CreatedAt).
- `CardConfiguration.cs``HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||…||coalesce(\"Contact\",''))", stored:true)` + GIN `IX_Cards_SearchTsv` (Ruling 6; поля поиска leads — Title+Summary+SourceMsg+Contact).
- `Persistence/TenantDbContext.cs` — DbSet'ы `QueueItems/RejectedItems/DedupEntries` + `ApplyConfiguration` (без `ApplyConfigurationsFromAssembly`, паттерн этапов 1–3).
- `Migrations/TenantDb/20260906165058_TenantPipeline.cs` (+ `.Designer.cs`, обновлён `TenantDbContextModelSnapshot.cs`) — миграция.
## Миграция и psql-приёмка
- Создана: `dotnet ef migrations add TenantPipeline --context TenantDbContext --output-dir Migrations/TenantDb --project Deal.Infrastructure --startup-project Deal.Api` (из `src/core`; dotnet-ef 10.0.11 локальный).
- DDL: `AddColumn Cards.SearchTsv` (computed, stored:true) + `CreateTable` DedupEntries/QueueItems/RejectedItems (SearchTsv — computed-колонка прямо в `CreateTable`) — без схемы (search_path). PK: `PK_DedupEntries (Hash)`, `PK_QueueItems (Id)`, `PK_RejectedItems (Id)`.
- Применение: краткий старт `Deal.Api``TenantProvisioningService` применил миграцию к схеме дефолтного тенанта.
psql (`tenant_00000000000000000000000000000001`):
- Таблицы: `QueueItems, RejectedItems, DedupEntries` созданы (+ существующие Boards/Cards/…).
- `Cards.SearchTsv` и `RejectedItems.SearchTsv`: `is_generated = ALWAYS`, выражение `to_tsvector('russian'::regconfig, …)` (Postgres хранит только STORED) — данные dev-карточек пересчитаны автоматически.
- Индексы: `IX_QueueItems_Status_CreatedAt` (btree), `IX_RejectedItems_RejectedAt` (btree), `IX_Cards_SearchTsv` и `IX_RejectedItems_SearchTsv` (GIN).
- `__TenantMigrationsHistory` содержит `20260906165058_TenantPipeline` (после InitialTenant/TenantKanban).
## Валидация
- `dotnet build Deal.sln`: Предупреждений 0, Ошибок 0.
- `dotnet test tests/Deal.Tests.Unit`: 410/410 PASS (MarkerTests 2/2 PASS).
- Диагностики изменённых файлов — без ошибок/предупреждений.
## Отклонения и решения
- DB-дефолты колонок не заданы (`HasDefaultValue` не использован) — конвенция этапа 3 (EF опускает колонку в INSERT при CLR-дефолте): прототипные дефолты (`Status='new'`, `Force=false`, `Source='stop'`, `ChannelHue='#666'`, `Returned=false`) перенесены в C#-инициализаторы сущностей.
- Лимиты ≤6000/≤500/≤200 — сервисные (Ruling 2/10), колонки `text` как в эталоне CardEntity (SourceMsg — text); maxlength в БД не заводили.
- `RejectedItems.MsgAt` — NOT NULL (прототип db.py допускал NULL; план Ruling 1(а) явно пометил nullable только MsgId и ReturnedAt — следовали плану).
- Первый старт Api с `--no-build` упал на `PendingModelChangesWarning` (сборка была до генерации миграции — EF не видел TenantPipeline в assembly); после `dotnet build` повторный старт — чисто. Это артефакт порядка команд, не кода.
@@ -0,0 +1,203 @@
#!/usr/bin/env sh
# Task 10 curl-приёмка: POST /api/admin/tick реальный (pipeline+pump+purge) и POST /api/admin/fts/rebuild на :5080
# (план Task 10 L462480; Rulings 6/8/9/10). Сценарий: чистка pipeline-таблиц и карточек t10_* → запуск Deal.Api
# с DEAL_DEMO=1 (Development) → 401 без куки (tick/fts) → login admin/admin → demo/ingest стоп-фразы и вакансии
# → POST /admin/tick: storage+reminders+pipeline{staged/rulesStored/aiStored}+queue:0 → /pipeline/rejected:
# стоп-фраза с kw → /leads: карточка inbox из вакансии (sourceDialogId t10_vacancy) → psql состаривает запись
# отсева (4 дн.) → повторный tick: storage.purgedRejected=1, отсев пуст → fts/rebuild дважды: {ok,ready} →
# logout → 401. В конце — остановка приложения и очистка строк/карточек приёмки.
set -u
BASE_URL="http://localhost:5080"
API_DIR="C:/telbase/src/core/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
WORK="/tmp/task10"
JAR="$WORK/jar.txt"
OUT="$WORK/out.txt"
LOG="$WORK/api.log"
BODY_DIR="$WORK/bodies"
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
SCHEMA="tenant_00000000000000000000000000000001"
PASS_COUNT=0
FAIL_COUNT=0
APP_PID=""
check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1
shift
ok=1
for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then
ok=0
fi
done
if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*"
echo "--- ответ:"
cat "$OUT"
fi
}
check_absent() {
# $1 — описание; $2 — подстрока, которой НЕ должно быть в $OUT
desc=$1
pat=$2
if grep -qF -- "$pat" "$OUT"; then
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — найдено нежелательное: $pat"
echo "--- ответ:"
cat "$OUT"
else
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
fi
}
stop_app() {
if [ -n "${1:-}" ] && kill -0 "$1" 2>/dev/null; then
kill "$1" 2>/dev/null
sleep 2
if netstat -ano 2>/dev/null | grep ':5080' | grep -qi listening; then
taskkill //F //PID "$1" 2>/dev/null
sleep 1
fi
fi
echo " [INFO] Deal.Api остановлен"
}
psql_clear_task10() {
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"QueueItems\";" >/dev/null 2>&1
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"RejectedItems\";" >/dev/null 2>&1
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"DedupEntries\";" >/dev/null 2>&1
# Карточки, созданные приёмкой Task 10 (dialogId t10_*) — повторяемость между прогонами.
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Cards\" WHERE \"SourceDialogId\" LIKE 't10\_%';" >/dev/null 2>&1
}
cleanup() {
echo
echo "== Завершение (trap): остановка процесса и очистка строк/карточек приёмки =="
stop_app "$APP_PID"
psql_clear_task10
rm -rf "$WORK"
}
trap cleanup EXIT INT TERM
rm -rf "$WORK"
mkdir -p "$BODY_DIR"
echo "== 0. Очистка pipeline-таблиц и карточек t10_* дефолтного тенанта (повторяемость приёмки) =="
PID_5080=$(netstat -ano 2>/dev/null | grep ':5080' | grep -i listening | awk '{print $NF}' | head -1)
if [ -n "$PID_5080" ]; then
echo " [WARN] порт 5080 занят pid $PID_5080 — останавливаю"
taskkill //F //PID "$PID_5080" >/dev/null 2>&1
sleep 1
fi
psql_clear_task10
echo
echo "== 0a. Запуск Deal.Api на :5080 с DEAL_DEMO=1 (Development) =="
cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development DEAL_DEMO=1 "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$!
i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1))
if [ "$i" -ge 45 ]; then
echo " [FAIL] сервер не поднялся за 45 с (лог: $LOG)"
tail -n 30 "$LOG"
exit 1
fi
sleep 1
done
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
sleep 2
echo
echo "== 1. 401 без сессии: /api/admin/tick и /api/admin/fts/rebuild =="
curl -s -w "\n[HTTP:%{http_code}]" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
check "POST /admin/tick без куки → 401" '[HTTP:401]' 'Требуется авторизация'
curl -s -w "\n[HTTP:%{http_code}]" -X POST "$BASE_URL/api/admin/fts/rebuild" > "$OUT"
check "POST /admin/fts/rebuild без куки → 401" '[HTTP:401]' 'Требуется авторизация'
echo
echo "== 2. Login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
check "login 200 ok" '[HTTP:200]' '"ok":true'
echo
echo "== 3. demo/ingest стоп-фразы (dialog t10_stop) и вакансии (dialog t10_vacancy) → очередь 2 =="
cat > "$BODY_DIR/ingest_stop.json" <<'EOF'
{"text":"Предлагаю взаимный пиар: разместим посты друг друга бесплатно, подпишемся взаимно.","dialogId":"t10_stop","channelName":"T10-Канал","channelHandle":"t10_stop","channelHue":"#a00","msgId":20001}
EOF
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/demo/ingest" \
-H "Content-Type: application/json" --data @"$BODY_DIR/ingest_stop.json" > "$OUT"
check "ingest стоп-фразы 200 {ok, id p_, new=1}" '[HTTP:200]' '"ok":true' '"id":"p_' '"new":1'
cat > "$BODY_DIR/ingest_vacancy.json" <<'EOF'
{"text":"Вакансия: Middle Python разработчик, удалённая работа, бюджет 1600-2200$, стек Python и FastAPI, контакт @crm_head","dialogId":"t10_vacancy","channelName":"T10-Канал","channelHandle":"t10_vacancy","channelHue":"#0a7","msgId":20002}
EOF
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/demo/ingest" \
-H "Content-Type: application/json" --data @"$BODY_DIR/ingest_vacancy.json" > "$OUT"
check "ingest вакансии 200 {ok, id p_, new=2}" '[HTTP:200]' '"ok":true' '"id":"p_' '"new":2' '"total":2'
echo
echo "== 4. POST /api/admin/tick — ответ {storage, reminders, pipeline, queue} =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
check "tick 200: форма storage/reminders/pipeline/queue" '[HTTP:200]' '"storage":{' '"reminders":[]' '"pipeline":{' '"queue":0'
check "storage: archived/purged* счётчики (purgedRejected 0 — отсев свежий)" '"archived":0' '"purgedArchive":0' '"purgedTrash":0' '"purgedRejected":0'
check "pipeline: вакансия → staged 1/aiStored 1; стоп-фраза → отсев (см. шаг 5); rulesStored — как в прототипе всегда 0 (ключ словаря L921 не инкрементируется)" '"staged":1' '"aiStored":1' '"rulesStored":0'
check_absent "queue после tick = 0 (строки разобраны)" '"queue":1'
echo
echo "== 5. GET /pipeline/rejected — стоп-фраза в отсеве (source правила, kw «взаимный пиар») =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/pipeline/rejected" > "$OUT"
check "rejected 200: запись стоп-фразы" '[HTTP:200]' '"stageLabel":"стоп-фраза"' '"sourceLabel":"правила"' '"kw":"взаимный пиар"' '"total":1'
echo
echo "== 6. GET /api/leads — карточка из вакансии создана pump'ом (inbox, t10_vacancy) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/leads" > "$OUT"
check "leads 200: карточка вакансии в inbox" '[HTTP:200]' '"sourceDialogId":"t10_vacancy"' '"col":"inbox"'
echo
echo "== 7. Очистка отсева в тике: состариваем запись (RejectedAt 4 дн.) → tick purgedRejected=1 =="
$PSQL_BASE -c "UPDATE \"$SCHEMA\".\"RejectedItems\" SET \"RejectedAt\" = now() - interval '4 days';" >/dev/null 2>&1
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
check "tick 200: purge отсева 3 дн. → purgedRejected=1" '[HTTP:200]' '"purgedRejected":1'
check "tick: очередь пуста, счётчики pump нулевые" '"queue":0' '"staged":0' '"rulesStored":0'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/pipeline/rejected" > "$OUT"
check "rejected: отсев очищен (total 0)" '[HTTP:200]' '"items":[]' '"total":0'
echo
echo "== 8. POST /api/admin/fts/rebuild — {ok:true, ready:true}, идемпотентно =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/fts/rebuild" > "$OUT"
check "fts/rebuild 200 ok/ready" '[HTTP:200]' '"ok":true' '"ready":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/fts/rebuild" > "$OUT"
check "fts/rebuild повторно 200 (идемпотентность CREATE INDEX IF NOT EXISTS)" '[HTTP:200]' '"ok":true' '"ready":true'
echo
echo "== 9. Logout → 401 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
check "logout 200 ok" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
check "POST /admin/tick после logout → 401" '[HTTP:401]' 'Требуется авторизация'
echo
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
stop_app "$APP_PID"
APP_PID=""
psql_clear_task10
if [ "$FAIL_COUNT" -gt 0 ]; then
exit 1
fi
exit 0
@@ -0,0 +1,68 @@
# Task 10 — «POST /admin/tick и /admin/fts/rebuild реальные + SSE-тост отсева» — отчёт
Статус: **DONE**. Сборка 0 warnings / 0 errors; тесты **524/524 PASS** (521 этапа 9 + 3 новых: 2 — AdminTickOrchestrator,
1 — StorageToastPublisher); curl-приёмка на :5080 — **18/18 PASS**. План:
`docs/superpowers/plans/2026-09-05-deal-stage4-pipeline.md` Task 10 (L462480), Rulings 6/8/9/10; прототип —
dashboard_routes.py L261264/L327337, leads.py tick_storage/notify L454504, fts.py rebuild L4867.
## Файлы
### Создан — `Deal.Infrastructure/Services/FtsMaintenance.cs`
`RebuildAsync(TenantDbContext, ILogger)` — реальная идемпотентная пересборка FTS (Ruling 6): SearchTsv —
STORED-колонки (миграция TenantPipeline), поэтому «пересборка» = `CREATE INDEX IF NOT EXISTS IX_Cards_SearchTsv /
IX_RejectedItems_SearchTsv` (GIN, самовосстановление) + `ANALYZE Cards/RejectedItems`. Сбой логируется →
false (эндпоинт отвечает `{ok:false, ready:false}` — как fts.rebuild() python, кнопка Settings по ready
показывает ошибку).
### Создан — `Deal.Api/AdminTickOrchestrator.cs` + `Deal.Api/AdminTickResultDto.cs`
**Отклонение от буквы плана (задокументировано)**: план кладёт состав тика прямо в эндпоинт, но требования
задачи — unit-тесты «tick-ответ (storage+pipeline+queue)» и «pump-исключение не роняет тик»; приватный
handler непроверяем без HTTP-хоста (в тест-проекте его нет, endpoint-слои у нас покрываются curl). Логика
вынесена в Api-слой `AdminTickOrchestrator` (паттерн StorageToastPublisher/StorageTickSchedulerTests — Api-классы
unit-тестируются на фейках). Порядок 1:1 с admin_tick L327337: `StorageTickService.TickAsync`
`PipelineProcessingService.PurgeExpiredAsync` (merge в `storage.purgedRejected`, Ruling 9) → SSE-тосты
(StorageToastPublisher; до pump, как L333) → `PipelineWorkerService.PumpOnceAsync` (catch — НЕ роняет тик:
`OperationCanceledException` пробрасывается, прочие логируются → pipeline `{}` как при занятом локе L901–902) →
SSE `new_lead` по `CreatedCards` (Ruling 8/9) → `queue` = QueueCountsAsync.Total после pump (queue_len L337).
`AdminTickResultDto` — форма `{storage, reminders:[], pipeline:<dict>, queue}`; pipeline — словарь 9 ключей python
L921 (staged…noBudget; CreatedCards в wire не выходят — ушли отдельными SSE). DI: `AddScoped` в Program.cs
(AdminTickOrchestrator + FtsMaintenance).
### Изменён — `Deal.Api/Endpoints/StorageEndpoints.cs`
`AdminTickAsync` — 401-гейт → `AdminTickOrchestrator.TickAsync` (весь состав тика/публикации у оркестратора);
`FtsRebuildAsync` — 401-гейт → `FtsMaintenance.RebuildAsync``{ok, ready}`.
### Изменён — `Deal.Api/Events/StorageToastPublisher.cs`
Ветка `PurgedRejected > 0` → toast «Отсев очищен: N записей (3 дн.)» (trash) — notify_tick_stats L503504
(правка, обещанная review этапа 3).
### Изменён — `Deal.Api/Program.cs`; тесты
Регистрации новых Api/Infrastructure-сервисов. Тесты: `StorageToastPublisherTests` (4 тоста включая отсев +
purgedRejected-only), новый `AdminTickOrchestratorTests` (тик: purge 3 дн. + merge в storage.purgedRejected +
pump-счётчики + queue + тост отсева + new_lead; сбой чтения очереди pump → тик жив, pipeline `{}`, строка в
очереди). `FakePipelineStore.ListAsync` → virtual (подкласс со сбоем в тесте). `PipelineProcessingServiceTests`
(purge 3 дн.) уже покрывал очистку — не дублировался.
## Проверка
1. `dotnet build Deal.sln` (src/core) — 0 warnings / 0 errors.
2. `dotnet test Deal.sln` (tests/Deal.Tests.Unit) — **524/524 PASS**.
3. curl-приёмка (`.superpowers/sdd/deal-stage4-pipeline/task-10-curl-acceptance.sh`, лог — task-10-curl-acceptance.log,
DEAL_DEMO=1, admin/admin, :5080) — **18/18 PASS**: 401 (tick/fts) → login → ingest стоп-фразы + вакансии →
tick: `{storage{archived:0,purgedRejected:0}, reminders:[], pipeline{staged:1,aiStored:1,…}, queue:0}`
/pipeline/rejected: запись «стоп-фраза»/«правила»/kw «взаимный пиар» → /leads: карточка inbox из вакансии
(sourceDialogId t10_vacancy) → psql-состаривание RejectedAt (−4 дн.) → повторный tick: `purgedRejected:1`,
отсев пуст → fts/rebuild дважды `{ok:true,ready:true}` (идемпотентно) → logout → 401.
## Решения и находки
- **rulesStored в pump всегда 0 — 1:1 с прототипом**: в `_pump_unlocked` (L921) ключ инициализируется 0 и НИГДЕ
не инкрементируется (используется только в pump-gate сумме L906); .NET-воркер (Task 8) повторяет это точно.
curl-ожидание rulesStored:1 было моей ошибкой — поправлено на rulesStored:0 (зафиксировано в тесте и скрипте).
- **pump-сбой не роняет тик** — требование задачи: исключение логируется (ILogger оркестратора), ответ 200 со
storage/queue и pipeline `{}`; `OperationCanceledException` пробрасывается (запрос отменён).
- **Отклонение файловой структуры от плана**: оркестратор+DTo в Api (см. выше) — обосновано тестируемостью;
purge-merge переиспользует StorageToastPublisher, который в Task 11 получит ту же ветку из фонового цикла.
## Concerns для Task 11/13
- T11: `StorageTickScheduler` должен после Kanban-тика звать `PipelineProcessingService.PurgeExpiredAsync` и
публиковать тост «Отсев очищен» (ветка публикатора готова); фоновый `PipelineWorkerScheduler` (2 с) + гейт.
- Сквозной return/rejected-return/clear на реальных записях (после отсева stop) — приёмка Task 13.
@@ -0,0 +1,251 @@
#!/usr/bin/env sh
# Task 11 curl-приёмка: фоновый цикл pump (2 с) + фоновая автоочистка отсева (30 с тик) на :5080
# (план Task 11 L482501; Rulings 8/9/11). Сценарий: чистка pipeline-таблиц и карточек t11_* → запуск
# Deal.Api с DEAL_DEMO=1 → login admin/admin → demo/ingest вакансии и стоп-фразы → БЕЗ ручного tick ждём,
# пока фоновый цикл (2 с) создаст карточку (GET /leads) и отсев (GET /pipeline/rejected, stats) →
# psql состаривает запись отсева (4 дн.) → при подписанном SSE ждём фоновую очистку (30 с тик): тост
# «Отсев очищен: 1 записей (3 дн.)» + rejected total 0 → logout → 401. В конце — остановка приложения.
set -u
BASE_URL="http://localhost:5080"
API_DIR="C:/telbase/src/core/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
WORK="/tmp/task11"
JAR="$WORK/jar.txt"
OUT="$WORK/out.txt"
LOG="$WORK/api.log"
SSE_LOG="$WORK/sse.log"
BODY_DIR="$WORK/bodies"
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
SCHEMA="tenant_00000000000000000000000000000001"
PASS_COUNT=0
FAIL_COUNT=0
APP_PID=""
SSE_PID=""
check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1
shift
ok=1
for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then
ok=0
fi
done
if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*"
echo "--- ответ:"
cat "$OUT"
fi
}
check_file() {
# $1 — описание; $2 — файл; $3 — подстрока, которая должна быть в файле
if grep -qF -- "$3" "$2"; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $1"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $1 — в файле нет: $3"
echo "--- файл ($2):"
cat "$2"
fi
}
wait_for() {
# $1 — описание; $2 — файл-источник; $3 — подстрока; $4 — попыток (шаг 1 с); $5… — аргументы curl
desc=$1
file=$2
pat=$3
tries=$4
shift 4
i=0
while [ "$i" -lt "$tries" ]; do
curl -s "$@" > "$file"
if grep -qF -- "$pat" "$file"; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc (попытка $((i + 1)))"
return 0
fi
i=$((i + 1))
sleep 1
done
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — условие не наступило за $tries с"
echo "--- последний ответ:"
cat "$file"
return 1
}
stop_app() {
if [ -n "${1:-}" ] && kill -0 "$1" 2>/dev/null; then
kill "$1" 2>/dev/null
sleep 2
if netstat -ano 2>/dev/null | grep ':5080' | grep -qi listening; then
taskkill //F //PID "$1" 2>/dev/null
sleep 1
fi
fi
echo " [INFO] Deal.Api остановлен"
}
psql_clear_task11() {
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"QueueItems\";" >/dev/null 2>&1
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"RejectedItems\";" >/dev/null 2>&1
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"DedupEntries\";" >/dev/null 2>&1
# Карточки, созданные приёмкой Task 11 (dialogId t11_*) — повторяемость между прогонами.
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Cards\" WHERE \"SourceDialogId\" LIKE 't11\_%';" >/dev/null 2>&1
}
cleanup() {
echo
echo "== Завершение (trap): остановка процессов и очистка строк/карточек приёмки =="
if [ -n "$SSE_PID" ] && kill -0 "$SSE_PID" 2>/dev/null; then
kill "$SSE_PID" 2>/dev/null
fi
stop_app "$APP_PID"
psql_clear_task11
rm -rf "$WORK"
}
trap cleanup EXIT INT TERM
rm -rf "$WORK"
mkdir -p "$BODY_DIR"
echo "== 0. Очистка pipeline-таблиц и карточек t11_* дефолтного тенанта (повторяемость приёмки) =="
PID_5080=$(netstat -ano 2>/dev/null | grep ':5080' | grep -i listening | awk '{print $NF}' | head -1)
if [ -n "$PID_5080" ]; then
echo " [WARN] порт 5080 занят pid $PID_5080 — останавливаю"
taskkill //F //PID "$PID_5080" >/dev/null 2>&1
sleep 1
fi
psql_clear_task11
echo
echo "== 0a. Запуск Deal.Api на :5080 с DEAL_DEMO=1 (Development) =="
cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development DEAL_DEMO=1 "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$!
i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1))
if [ "$i" -ge 45 ]; then
echo " [FAIL] сервер не поднялся за 45 с (лог: $LOG)"
tail -n 30 "$LOG"
exit 1
fi
sleep 1
done
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
echo
echo "== 1. Login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
check "login 200 ok" '[HTTP:200]' '"ok":true'
echo
echo "== 2. demo/ingest вакансии (t11_vacancy) и стоп-фразы (t11_stop) → очередь 2 =="
cat > "$BODY_DIR/ingest_vacancy.json" <<'EOF'
{"text":"Вакансия: Middle Python разработчик, удалённая работа, бюджет 1600-2200$, стек Python и FastAPI, контакт @crm_head","dialogId":"t11_vacancy","channelName":"T11-Канал","channelHandle":"t11_vacancy","channelHue":"#0a7","msgId":30001}
EOF
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/demo/ingest" \
-H "Content-Type: application/json" --data @"$BODY_DIR/ingest_vacancy.json" > "$OUT"
check "ingest вакансии 200 {ok, id p_, new=1}" '[HTTP:200]' '"ok":true' '"id":"p_' '"new":1'
cat > "$BODY_DIR/ingest_stop.json" <<'EOF'
{"text":"Предлагаю взаимный пиар: разместим посты друг друга бесплатно, подпишемся взаимно.","dialogId":"t11_stop","channelName":"T11-Канал","channelHandle":"t11_stop","channelHue":"#a00","msgId":30002}
EOF
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/demo/ingest" \
-H "Content-Type: application/json" --data @"$BODY_DIR/ingest_stop.json" > "$OUT"
check "ingest стоп-фразы 200 {ok, id p_, new=2}" '[HTTP:200]' '"ok":true' '"id":"p_' '"new":2' '"total":2'
echo
echo "== 3. БЕЗ ручного tick: фоновый цикл (2 с) разбирает очередь =="
echo "== 3a. Ждём карточку вакансии в /api/leads (до 15 с) =="
wait_for "карточка t11_vacancy появилась в /leads (inbox)" "$OUT" '"sourceDialogId":"t11_vacancy"' 15 -b "$JAR" "$BASE_URL/api/leads"
check "карточка в «Неразобранном»" '"col":"inbox"'
echo
echo "== 3b. Ждём отсев стоп-фразы в /api/pipeline/rejected (до 15 с) =="
wait_for "запись отсева t11_stop (стоп-фраза/правила/kw) появилась" "$OUT" '"kw":"взаимный пиар"' 15 -b "$JAR" "$BASE_URL/api/pipeline/rejected"
check "форма отсева: source правила, stageLabel стоп-фраза" '"stageLabel":"стоп-фраза"' '"sourceLabel":"правила"' '"total":1'
echo
echo "== 3c. Очередь разобрана фоном (queue total 0), stats показывают отсев =="
wait_for "queue: total 0 (обе строки разобраны фоном)" "$OUT" '"total":0' 10 -b "$JAR" "$BASE_URL/api/pipeline/queue?limit=120"
check "queue: items пуст" '"items":[]'
curl -s -b "$JAR" "$BASE_URL/api/pipeline/stats" > "$OUT"
check "stats: очередь 0, отсев 1" '"queue":{' '"new":0' '"rejected":1'
echo
echo "== 4. Фоновая автоочистка отсева (30 с тик): SSE-подписка → состариваем RejectedAt (4 дн.) =="
curl -s -N -b "$JAR" "$BASE_URL/api/events" > "$SSE_LOG" 2>&1 &
SSE_PID=$!
sleep 2
$PSQL_BASE -c "UPDATE \"$SCHEMA\".\"RejectedItems\" SET \"RejectedAt\" = now() - interval '4 days' WHERE \"DialogId\" = 't11_stop';" >/dev/null 2>&1
echo " [INFO] RejectedAt записи t11_stop состарено на 4 дня; ждём тик правил хранения (≤45 с)..."
i=0
while [ "$i" -lt 45 ]; do
if grep -qF "Отсев очищен: 1 записей (3 дн.)" "$SSE_LOG"; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] SSE-тост «Отсев очищен: 1 записей (3 дн.)» пришёл подписчику (попытка $((i + 1)))"
break
fi
i=$((i + 1))
sleep 1
done
if [ "$i" -ge 45 ]; then
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] SSE-тост очистки отсева не пришёл за 45 с"
echo "--- sse.log:"; cat "$SSE_LOG"
fi
echo
echo "== 4a. Отсев очищен фоном: /pipeline/rejected пуст (до 45 с) =="
wait_for "rejected: total 0 после фоновой очистки" "$OUT" '"items":[]' 45 -b "$JAR" "$BASE_URL/api/pipeline/rejected"
check "rejected total 0" '"total":0'
$PSQL_BASE -c "SELECT count(*) FROM \"$SCHEMA\".\"RejectedItems\" WHERE \"DialogId\" = 't11_stop';" > "$OUT"
check_file "psql: строк t11_stop в RejectedItems не осталось" "$OUT" "0"
kill "$SSE_PID" 2>/dev/null
SSE_PID=""
echo
echo "== 5. Лог приложения: циклы без ошибок (нет «не удался» по циклам pump/хранения) =="
if grep -q "Цикл разбора очереди\|Цикл правил хранения" "$LOG"; then
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] в логе приложения есть ошибки фоновых циклов:"
grep "Цикл разбора очереди\|Цикл правил хранения" "$LOG"
else
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] лог без ошибок фоновых циклов"
fi
echo
echo "== 6. Logout → 401 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
check "logout 200 ok" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
check "POST /admin/tick после logout → 401" '[HTTP:401]' 'Требуется авторизация'
echo
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
stop_app "$APP_PID"
APP_PID=""
psql_clear_task11
if [ "$FAIL_COUNT" -gt 0 ]; then
exit 1
fi
exit 0
@@ -0,0 +1,76 @@
# Task 11 — «Фоновые циклы: pump 2 с (PipelineWorkerScheduler) + purge отсева (30 с тик)» — отчёт
Статус: **DONE**. Сборка 0 warnings / 0 errors; тесты **534/534 PASS** (524 этапа 10 + 10 новых: 4 — PipelinePumpGate,
4 — PipelineWorkerScheduler, 1 — AdminTickOrchestrator «гейт занят», 1 — StorageTickScheduler purge); curl-приёмка на
:5080 — **17/17 PASS**. План: `docs/superpowers/plans/2026-09-05-deal-stage4-pipeline.md` Task 11 (L482501),
Rulings 8/9/11; прототип — main.py `_pipeline_loop` L7988 / `_storage_loop` L4353, pipeline.py L3842 (lock), L901902.
## Файлы
### Создан — `Deal.Api/PipelinePumpGate.cs`
Общий воркер-гейт pump тенанта (Ruling 8; аналог asyncio.Lock pipeline.py L3842): per-tenant атомарный флаг в
ConcurrentDictionary (`TryAdd`/`TryRemove`), `TryEnter(Guid tenantId)` / `Exit(Guid tenantId)`. Прототип держит один
глобальный lock и при занятом локе возвращает `{}` (L901–902) — здесь гейт на тенанта (у каждого своя очередь в
своей схеме): admin/tick и фоновый цикл не разбирают очередь одного тенанта одновременно; занятый гейт = пропуск
прохода (не ожидание). Потокобезопасен (как Interlocked-гварды StorageTickScheduler/RatesRefreshScheduler).
### Создан — `Deal.Api/PipelineWorkerScheduler.cs`
IHostedService (эталон StorageTickScheduler/RatesRefreshScheduler): Timer 2 с (1:1 `asyncio.sleep(2)` main.py L87),
первый проход сразу после старта. Проход: системный scope → ITenantRepository.ListAsync → на каждый тенант свой
scope + `ITenantContext.SetTenant``PipelinePumpGate.TryEnter``PipelineWorkerService.PumpOnceAsync` → SSE
`new_lead` по `CreatedCards` (SseBroker, в канал тенанта; без подписчиков — no-op). Reset контекста и Exit гейта —
в finally. Занятый гейт (ручной tick) — молчаливый пропуск тенанта; pump одного тенанта не валит проход (лог
warning, остальные обрабатываются); OCE пробрасывается; in-flight guard (Interlocked) — перекрывающиеся проходы
исключены; StopAsync — graceful (таймер стоп + отмена текущего прохода). Пустая очередь — тихий no-op.
### Изменён — `Deal.Api/AdminTickOrchestrator.cs`
Pump тика теперь под тем же `PipelinePumpGate` (заметка ревью T10): `TryEnter(tenantId)` перед
`PumpOnceAsync`; гейт занят (фоновый цикл) — pipeline ответа `{}` (как при занятом локе L901–902), очередь ждёт
следующего срабатывания; Exit — в finally (включая OCE). Purge отсева/тосты тика не гейтятся (в прототипе purge —
в tick_storage, не в pump).
### Изменён — `Deal.Api/Hosting/StorageTickScheduler.cs`
После Kanban-тика каждого тенанта (в том же tenant-scope) — `PipelineProcessingService.PurgeExpiredAsync`
(отсев старше 3 суток, Ruling 8/9, tick_storage L485493): результат вливается в `storage.purgedRejected`,
SSE-тост «Отсев очищен: N записей (3 дн.)» публикует существующая ветка StorageToastPublisher. Фоновая
автоочистка отсева живёт в 30-с цикле хранения (как в прототипе), а не в 2-с pump-цикле.
### Изменён — `Deal.Api/Program.cs`
`AddSingleton<PipelinePumpGate>()` + `AddHostedService<PipelineWorkerScheduler>()` (после StorageTickScheduler;
Bootstrap уже отработал — первый проход после провижининга схем).
## Тесты
- `PipelinePumpGateTests` (4): первый вход выигрывает (второй — false); Exit освобождает; разные тенанты входят
независимо; Exit без входа не ломает состояние.
- `PipelineWorkerSchedulerTests` (4): проход pump'ит ВСЕ тенанты в собственных scope (очереди пусты, карточки
inbox, new_lead в канал каждого тенанта, AsyncLocal сброшен); пустые очереди — no-op без публикаций; сбой pump
тенанта A (ListAsync бросает) не роняет B (+ warning в логе); занятый гейт тенанта A (ручной tick) — цикл
пропускает A, очередь ждёт следующего срабатывания, B обработан, гейт A не освобождён циклом. Провайдер —
реальные сервисы модуля Pipeline на тенант-фейках (эталон StorageTickSchedulerTests).
- `AdminTickOrchestratorTests` (+1): гейт занят → тик возвращает storage + пустой pipeline, очередь не тронута,
гейт остаётся за фоновым воркером.
- `StorageTickSchedulerTests` (+1 purge + DI-расширение): фоновая очистка удаляет запись старше 3 суток, свежая
остаётся, тост «Отсев очищен» — только в канал тенанта с ненулевым счётчиком.
## Проверка
1. `dotnet build Deal.sln` (src/core) — 0 warnings / 0 errors.
2. `dotnet test Deal.sln` (tests/Deal.Tests.Unit) — **534/534 PASS**.
3. curl-приёмка (`.superpowers/sdd/deal-stage4-pipeline/task-11-curl-acceptance.sh`, лог — task-11-curl-acceptance.log,
DEAL_DEMO=1, admin/admin, :5080) — **17/17 PASS**: ingest вакансии + стоп-фразы → БЕЗ ручного tick в ~2 с
карточка в /leads (inbox) и отсев «стоп-фраза/правила/kw» в /pipeline/rejected (stats queue 0/rejected 1,
queue total 0) → psql RejectedAt 4 дн. при подписанном SSE: тост «Отсев очищен: 1 записей (3 дн.)» через
~25 с, rejected total 0, строки в БД нет → лог приложения без ошибок циклов → logout/401.
## Решения и находки
- **Gate — флаг «пропуск», не ожидание**: 1:1 с прототипом (L901–902 «занятый lock → {}») — ни тик, ни цикл не
блокируются, очередь всегда дождётся следующего срабатывания (2 с/следующий тик).
- **Purge — только в 30-с цикле хранения** (StorageTickScheduler), как прототип (tick_storage L485493 внутри
_storage_loop); в 2-с pump-цикле отсев не чистится.
- **SSE-тост purge в curl** подтверждён реальной подпиской /api/events (в отличие от T10, где ветка покрывалась
только unit): подписчик получил тост в пределах штатного 30-с тика.
- **Dev-замечание**: в тест-провайдере регистрация фейк-реестра обязана быть через `ITenantRepository` (а не
конкретный тип) — `AddSingleton(instance)` регистрирует compile-time тип.
## Concerns для Task 13
- Сквозная приёмка Task 13: pump-цикл 2 с уже разбирает очередь сам — ручной tick в сценарии Task 13 остаётся
для детерминированных шагов (age-lead, return и т.п.), фон не мешает (гейт/пустая очередь — no-op).
@@ -0,0 +1,268 @@
#!/usr/bin/env sh
# Task 12 curl-приёмка: FTS-поиск карточек GET /api/search на :5080
# (план Task 12 L501517; Ruling 6; leads.py search L509551). Сценарий: чистка t12_* → запуск Deal.Api
# с DEAL_DEMO=1 → login admin/admin → demo/ingest 4 карточек (python×2, go, «работой»-морфология) →
# pump (tick/фон 2 с) → /api/search: q=python → 2 карточки, релевантная (title) первой, messages:[];
# q=работа → карточка по tsvector-морфологии («работой», подстроки «работа» в тексте нет → LIKE не мог);
# q=go → карточка по слову title; q<2 → пусто; q-без-совпадений → пусто; logout → 401.
set -u
BASE_URL="http://localhost:5080"
API_DIR="C:/telbase/src/core/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
WORK="/tmp/task12"
JAR="$WORK/jar.txt"
OUT="$WORK/out.txt"
LOG="$WORK/api.log"
BODY_DIR="$WORK/bodies"
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
SCHEMA="tenant_00000000000000000000000000000001"
PASS_COUNT=0
FAIL_COUNT=0
APP_PID=""
check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1
shift
ok=1
for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then
ok=0
fi
done
if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*"
echo "--- ответ:"
cat "$OUT"
fi
}
wait_for() {
# $1 — описание; $2 — файл-источник; $3 — подстрока; $4 — попыток (шаг 1 с); $5… — аргументы curl
desc=$1
file=$2
pat=$3
tries=$4
shift 4
i=0
while [ "$i" -lt "$tries" ]; do
curl -s "$@" > "$file"
if grep -qF -- "$pat" "$file"; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc (попытка $((i + 1)))"
return 0
fi
i=$((i + 1))
sleep 1
done
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — условие не наступило за $tries с"
echo "--- последний ответ:"
cat "$file"
return 1
}
stop_app() {
if [ -n "${1:-}" ] && kill -0 "$1" 2>/dev/null; then
kill "$1" 2>/dev/null
sleep 2
if netstat -ano 2>/dev/null | grep ':5080' | grep -qi listening; then
taskkill //F //PID "$1" 2>/dev/null
sleep 1
fi
fi
echo " [INFO] Deal.Api остановлен"
}
psql_clear_task12() {
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"QueueItems\";" >/dev/null 2>&1
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"RejectedItems\";" >/dev/null 2>&1
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"DedupEntries\";" >/dev/null 2>&1
# Карточки, созданные приёмкой Task 12 (dialogId t12_*) — повторяемость между прогонами.
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Cards\" WHERE \"SourceDialogId\" LIKE 't12\_%';" >/dev/null 2>&1
}
cleanup() {
echo
echo "== Завершение (trap): остановка процессов и очистка строк/карточек приёмки =="
stop_app "$APP_PID"
psql_clear_task12
rm -rf "$WORK"
}
trap cleanup EXIT INT TERM
rm -rf "$WORK"
mkdir -p "$BODY_DIR"
echo "== 0. Очистка pipeline-таблиц и карточек t12_* дефолтного тенанта (повторяемость приёмки) =="
PID_5080=$(netstat -ano 2>/dev/null | grep ':5080' | grep -i listening | awk '{print $NF}' | head -1)
if [ -n "$PID_5080" ]; then
echo " [WARN] порт 5080 занят pid $PID_5080 — останавливаю"
taskkill //F //PID "$PID_5080" >/dev/null 2>&1
sleep 1
fi
psql_clear_task12
echo
echo "== 0a. Запуск Deal.Api на :5080 с DEAL_DEMO=1 (Development) =="
cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development DEAL_DEMO=1 "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$!
i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1))
if [ "$i" -ge 45 ]; then
echo " [FAIL] сервер не поднялся за 45 с (лог: $LOG)"
tail -n 30 "$LOG"
exit 1
fi
sleep 1
done
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
echo
echo "== 1. Login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
check "login 200 ok" '[HTTP:200]' '"ok":true'
echo
echo "== 2. demo/ingest 4 карточек: t12_a (python в title), t12_b (python только после 140 симв.),"
echo "== t12_c (GO в title), t12_work (только слово «работой» — морфология FTS) =="
cat > "$BODY_DIR/ingest_a.json" <<'EOF'
{"text":"Вакансия: Middle Python-разработчик для Telegram-бота, стек Python/FastAPI/PostgreSQL, проект на несколько месяцев, удалённо, бюджет 2200-2500$, контакт @a_dev","dialogId":"t12_a","channelName":"T12-Канал","channelHandle":"t12_a","channelHue":"#0a7","msgId":31001}
EOF
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/demo/ingest" \
-H "Content-Type: application/json" --data @"$BODY_DIR/ingest_a.json" > "$OUT"
check "ingest t12_a 200 {ok, id p_, new=1}" '[HTTP:200]' '"ok":true' '"id":"p_' '"new":1'
cat > "$BODY_DIR/ingest_b.json" <<'EOF'
{"text":"Ищем исполнителя на разовый проект: создание Telegram-бота для автоматизации заявок, нужен опыт интеграции сторонних API и умение разбираться в чужом коде, проект полностью удалённый, подробности и примеры кейсов присылайте в личные сообщения, оплата 1500 долларов помесячно, нужен человек минимум на 2 месяца. Знание python будет плюсом. Контакт @b_dev","dialogId":"t12_b","channelName":"T12-Канал","channelHandle":"t12_b","channelHue":"#0b8","msgId":31002}
EOF
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/demo/ingest" \
-H "Content-Type: application/json" --data @"$BODY_DIR/ingest_b.json" > "$OUT"
check "ingest t12_b 200 {new=2}" '[HTTP:200]' '"ok":true' '"new":2'
cat > "$BODY_DIR/ingest_c.json" <<'EOF'
{"text":"Вакансия: Middle GO-разработчик для сервиса доставки, стек Go и PostgreSQL, офис или удалённо, зарплата 3000$ в месяц, контакт @c_dev","dialogId":"t12_c","channelName":"T12-Канал","channelHandle":"t12_c","channelHue":"#08c","msgId":31003}
EOF
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/demo/ingest" \
-H "Content-Type: application/json" --data @"$BODY_DIR/ingest_c.json" > "$OUT"
check "ingest t12_c 200 {new=3}" '[HTTP:200]' '"ok":true' '"new":3'
cat > "$BODY_DIR/ingest_work.json" <<'EOF'
{"text":"Ищу разработчика на замену: текущий исполнитель уже занят другой работой и не может продолжать, нужен человек на 2 месяца, оплата 1800$ в месяц, детали в личных сообщениях, контакт @w_dev","dialogId":"t12_work","channelName":"T12-Канал","channelHandle":"t12_work","channelHue":"#08c","msgId":31004}
EOF
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/demo/ingest" \
-H "Content-Type: application/json" --data @"$BODY_DIR/ingest_work.json" > "$OUT"
check "ingest t12_work 200 {new=4, total=4}" '[HTTP:200]' '"ok":true' '"new":4' '"total":4'
echo
echo "== 3. Разбор очереди (ручной tick + фоновый цикл 2 с): ждём 4 карточки в /api/leads (до 20 с) =="
curl -s -b "$JAR" -X POST "$BASE_URL/api/admin/tick" > /dev/null
i=0
while [ "$i" -lt 20 ]; do
curl -s -b "$JAR" "$BASE_URL/api/leads" > "$OUT"
n=$(grep -o '"sourceDialogId"' "$OUT" | wc -l | tr -d ' ')
if [ "$n" = "4" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] в /leads все 4 карточки t12_* (попытка $((i + 1)))"
break
fi
i=$((i + 1))
sleep 1
done
if [ "$i" -ge 20 ]; then
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] карточки t12_* не появились в /leads за 20 с (найдено $n из 4)"
echo "--- последний ответ:"; cat "$OUT"
fi
check "карточки в «Неразобранном»" '"col":"inbox"'
echo
echo "== 4. GET /api/search?q=python → 2 карточки (t12_a в title, t12_b в source), релевантная первой =="
curl -s -G -b "$JAR" "$BASE_URL/api/search" --data-urlencode "q=python" > "$OUT"
n=$(grep -o '"sourceDialogId"' "$OUT" | wc -l | tr -d ' ')
if [ "$n" = "2" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] q=python: найдено ровно 2 карточки (t12_a+t12_b)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] q=python: ожидалось 2 карточки, найдено $n"
echo "--- ответ:"; cat "$OUT"
fi
first=$(grep -o '"sourceDialogId":"t12_[a-z_]*"' "$OUT" | head -1 | tr -d '"' | cut -d: -f2)
if [ "$first" = "t12_a" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] q=python: релевантная первой (ts_rank: слово в title > в source) — $first"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] q=python: первая карточка не t12_a, а '$first'"
echo "--- ответ:"; cat "$OUT"
fi
check "обе карточки с полями §4.1 (col inbox, sourceDialogId)" '"col":"inbox"' '"sourceDialogId":"t12_a"' '"sourceDialogId":"t12_b"'
check "q=python: messages пуст" '"messages":[]'
echo
echo "== 5. GET /api/search?q=работа → 1 карточка t12_work (FTS-морфология: в тексте «работой»,"
echo "== подстроки «работа» нет — LIKE-путь не мог сработать, только tsvector) =="
# q передаётся percent-кодированным (UTF-8: %D1%80%D0%B0%D0%B1%D0%BE%D1%82%D0%B0) — нативный curl в git-bash
# искажает не-ASCII argv (кодировка аргументов Windows), карточка в БД по слову находится (проверено psql).
curl -s -b "$JAR" "$BASE_URL/api/search?q=%D1%80%D0%B0%D0%B1%D0%BE%D1%82%D0%B0" > "$OUT"
n=$(grep -o '"sourceDialogId"' "$OUT" | wc -l | tr -d ' ')
if [ "$n" = "1" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] q=работа: найдена ровно 1 карточка"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] q=работа: ожидалась 1 карточка, найдено $n"
echo "--- ответ:"; cat "$OUT"
fi
check "q=работа: это t12_work (tsvector по «работой»)" '"sourceDialogId":"t12_work"'
check "q=работа: messages пуст" '"messages":[]'
echo
echo "== 6. GET /api/search?q=go → карточка t12_c (слово из title, FTS/LIKE) =="
curl -s -G -b "$JAR" "$BASE_URL/api/search" --data-urlencode "q=go" > "$OUT"
check "q=go: нашлась t12_c" '"sourceDialogId":"t12_c"'
check "q=go: messages пуст" '"messages":[]'
echo
echo "== 7. q<2 символов → пусто (Ruling 6: min 2) =="
curl -s -b "$JAR" "$BASE_URL/api/search?q=%D1%80" > "$OUT"
check "q=р (1 символ): leads пуст" '"leads":[]'
check "q=р: messages пуст" '"messages":[]'
curl -s -G -b "$JAR" "$BASE_URL/api/search" --data-urlencode "q=" > "$OUT"
check "q пустой: leads пуст" '"leads":[]'
echo
echo "== 8. q без совпадений → пусто =="
curl -s -G -b "$JAR" "$BASE_URL/api/search" --data-urlencode "q=несуществующеесловоxyz" > "$OUT"
check "q без совпадений: leads пуст" '"leads":[]'
echo
echo "== 9. Logout → /api/search после logout → 401 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
check "logout 200 ok" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -G "$BASE_URL/api/search" --data-urlencode "q=python" > "$OUT"
check "GET /api/search после logout → 401" '[HTTP:401]' 'Требуется авторизация'
echo
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
stop_app "$APP_PID"
APP_PID=""
psql_clear_task12
if [ "$FAIL_COUNT" -gt 0 ]; then
exit 1
fi
exit 0
@@ -0,0 +1,69 @@
# Task 12 — «Полнотекстовый поиск карточек — /api/search (FTS + LIKE)» — отчёт
Статус: **DONE**. Сборка 0 warnings / 0 errors; тесты **535/535 PASS** (534 этапа 11 + 1 новый:
`Search_DelegatesToStoreWithQueryAndLimit`; существующий `Search_QueryShorterThanTwoChars_ReturnsEmpty`
расширен проверкой «порт не зовётся»); curl-приёмка на :5080 — **22/22 PASS**. План:
`docs/superpowers/plans/2026-09-05-deal-stage4-pipeline.md` Task 12 (L501517), Ruling 6; прототип —
leads.py search L509551, fts.py; эталон реализации FTS+LIKE — PipelineStore.SearchIdsAsync (Task 3).
## Файлы
### Изменён — `Deal.Modules.Kanban/Application/IKanjStore.cs`
Новый метод порта `SearchCardsAsync(string q, int limit, CancellationToken)` (вариант плана «или новый метод» —
`CardsQuery` не менялся: поиску не нужен фильтр колонки, q/limit — параметры метода): полные CardDto
col != 'taken', FTS-кандидаты по убыванию ts_rank + LIKE-дополнение, внутри — ReceivedAt DESC. XML-doc 1:1
с Ruling 6. Владелец метода — Kanban (карточки — таблица Kanban `Cards`, цикла модулей нет).
### Изменён — `Deal.Infrastructure/Persistence/Repositories/KanbanStore.cs`
Реализация — один raw SQL через `FromSqlInterpolated` (параметризация, никакой конкатенации ввода; эталон
PipelineStore/RejectedItems): `WHERE "Col" <> @taken AND ("SearchTsv" @@ plainto_tsquery('russian', @q) OR
lower("Title"/"Summary"/"SourceMsg"/"Contact") LIKE @pattern) ORDER BY ts_rank("SearchTsv",
plainto_tsquery('russian', @q)) DESC, "ReceivedAt" DESC LIMIT @limit` → полные DTO через существующий
`ToCardDtosAsync` (комментарии/time). tsvector-колонка STORED (T1) — автоактуальна; plainto_tsquery со
стоп-словами → пустой tsquery: FTS даёт пусто, LIKE-ветка всё равно отрабатывает (как отсев-поиск).
### Изменён — `Deal.Modules.Kanban/Application/CardsService.cs`
`SearchCardsAsync` теперь делегирует порту (старый перебор по `ListCardsAsync` удалён вместе с
`ContainsQuery`): trim+lowercase → q<2 → пусто, порт не вызывается (поведение этапа 3, L511–512) → иначе
`store.SearchCardsAsync(lowered, SearchLimit=12, ct)`.
### Изменён — `Deal.Api/Endpoints/LeadsEndpoints.cs`
Только XML-doc GET /api/search («FTS + LIKE, Ruling 6/Task 12») — эндпоинт уже шёл через
`CardsService.SearchCardsAsync`, ответ `{leads, messages: []}` не менялся (поля §4.1 CardDto).
### Изменён — тесты
`FakeKanjStore.SearchCardsAsync` (реализация интерфейса): LIKE-семантика по 4 полям, col != 'taken',
ReceivedAt DESC, limit + запись вызова в `SearchCalls` (как FakeMlClient.Pushed). `CardsServiceTests`:
+`Search_DelegatesToStoreWithQueryAndLimit` (q≥2 → порт вызван с trimmed/lowercase q и лимитом 12, результат
порта возвращён); существующий тест q<2 расширен `Assert.Empty(store.SearchCalls)`.
## Проверка
1. `dotnet build Deal.sln` (src/core) — 0 warnings / 0 errors.
2. `dotnet test tests/Deal.Tests.Unit`**535/535 PASS**.
3. curl-приёмка (`.superpowers/sdd/deal-stage4-pipeline/task-12-curl-acceptance.sh`, лог —
task-12-curl-acceptance.log, DEAL_DEMO=1, admin/admin, :5080) — **22/22 PASS**: ingest 4 карточек
(t12_a: python в title; t12_b: python только после 140 симв. — не в title; t12_c: GO; t12_work: только
слово «работой») → карточки в /leads → `GET /api/search?q=python`: ровно 2 карточки, релевантная первой
t12_a (ts_rank: слово в title выше, чем в source), поля §4.1, `messages:[]`; `?q=работа`: ровно 1 —
t12_work **только через tsvector** (в тексте «работой», подстроки «работа» нет — LIKE-путь не мог
сработать; совпадение подтверждено psql `@@ plainto_tsquery`); `?q=go`: t12_c по слову title;
`?q=р` (1 символ) и пустой q → `leads:[]`; q без совпадений → `leads:[]`; logout → 401.
## Решения и находки
- **Поиск живёт в KanbanStore (владелец Cards), а не PipelineStore** — сверено с планом (Task 12 Files:
`KanbanStore.cs`); PipelineStore.FTS не трогался.
- **Один SQL вместо FTS ∪ LIKE двумя выборками** — буква Ruling 6 для /api/search («один SQL … ts_rank DESC,
ReceivedAt DESC, limit 12»); отсев-поиск (две выборки + merge) оставлен как есть (его Ruling 6 описывает
иначе — лимиты limit*2 и total-объединение).
- **Отклонение от буквы плана (задокументировано)**: `CardsQuery` не менялся — добавлен отдельный метод
порта (план допускает «или новый метод»): поиску не нужен Col-фильтр, q/limit — параметры вызова.
- **Находка curl**: нативный Windows-curl в git-bash искажает не-ASCII argv (кириллица в `--data-urlencode
"q=работа"` уходит битой) — q передаётся percent-кодированным UTF-8 (`%D1%80%D0%B0…`); карточка в БД по
слову находится (проверено psql ts_rank 0.08), приёмка зелёная.
- **Тест «морфология» честный**: текст t12_work содержит форму «работой», подстроки «работа» в тексте нет —
lower-LIKE не мог найти карточку, нашёл только tsvector (russian-стемминг), что и требовал Acceptance.
## Concerns для Task 13
- Сквозной сценарий Task 13: `GET /api/search?q=` по созданным карточкам уже покрыт (этот Task); psql-пункт
«SearchTsv заполнены» — виден в дебаг-прогоне (tsvector карточки содержит лексемы, GIN-индекс на месте из
T1/T10). В Task 13 остаётся обновить техническую документацию (раздел «Обработка/Pipeline», FTS).
@@ -0,0 +1,476 @@
#!/usr/bin/env sh
# Task 13 curl-приёмка (финал этапа 4): сквозной сценарий на :5080 (DEAL_DEMO=1, admin/admin)
# (план Task 13 L519-542, Ruling 11; T9-concern: return/dup-400/DELETE/clear/q-FTSLIKE на реальных
# записях отсева — закрывается здесь). Сценарий:
# 0) чистка pipeline-таблиц/карточек t13_*+demo_channel → запуск Deal.Api с DEAL_DEMO=1;
# 1) 401 без куки (pipeline/stats, queue, rejected, demo/ingest) → login → stats нули;
# 2) demo/ingest вакансии (demo_channel) → повтор dialogId+msgId → id:null (гвард) → tick → карточка
# в /leads (title/summary/stack/budget/converted/contacts/ch/sourceMsg/col inbox);
# 3) ingest короткого текста/стоп-фразы/резюме (настройки: stopPhrases=['взаимный пиар']) → tick →
# отсев rules (length/stop/resume c kw); повторный ingest текста вакансии (новый msgId) → отсев dup;
# budgetRequiredHire=true + вакансия без суммы → отсев «нет суммы»; msgAt старше 20 дн. → отсев
# «устарело» (карточки нет) → tick → /pipeline/queue пусто + /pipeline/stats + rejected 6;
# 4) psql: QueueItems=0, RejectedItems=6, DedupEntries=1 (LeadId=карточка вакансии), SearchTsv карточек;
# 5) GET /pipeline/rejected?q= — FTS (q=работа по «работой» — LIKE не мог) / LIKE (q=T13 по имени канала)
# / общий (q=взаимный); страница total 6;
# 6) return dup → 400; return stop → {returned:true} + очередь 1 → tick → карточка из возврата; повторный
# return → 400; DELETE /rejected/{resume} → ok; /rejected/clear → {cleared:5}; повторный clear → 0;
# 7) /api/search по карточкам (FTS); POST /admin/fts/rebuild → {ok,ready};
# 8) psql карточка ↔ dedup → DELETE /leads/{id} → dedup-строка удалена;
# 9) отсев purge 3 дн.: ingest стоп-фразы (t13_purge) → tick → rejected 1 → psql состаривает RejectedAt
# (−4 дн.) → SSE-подписка → tick → тост «Отсев очищен: 1 записей (3 дн.)» + rejected 0;
# 10) logout → 401 (stats/demo/ingest/tick). В конце — остановка приложения и чистка dev-БД.
set -u
BASE_URL="http://localhost:5080"
API_DIR="C:/telbase/src/core/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
WORK="/tmp/task13"
JAR="$WORK/jar.txt"
OUT="$WORK/out.txt"
LOG="$WORK/api.log"
SSE_LOG="$WORK/sse.log"
BODY_DIR="$WORK/bodies"
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
SCHEMA="tenant_00000000000000000000000000000001"
PASS_COUNT=0
FAIL_COUNT=0
APP_PID=""
SSE_PID=""
check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1
shift
ok=1
for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then
ok=0
fi
done
if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*"
echo "--- ответ:"
cat "$OUT"
fi
}
check_absent() {
# $1 — описание; $2 — подстрока, которой НЕ должно быть в $OUT
desc=$1
pat=$2
if grep -qF -- "$pat" "$OUT"; then
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — найдено нежелательное: $pat"
echo "--- ответ:"
cat "$OUT"
else
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
fi
}
wait_for() {
# $1 — описание; $2 — файл-источник; $3 — подстрока; $4 — попыток (шаг 1 с); $5… — аргументы curl
desc=$1
file=$2
pat=$3
tries=$4
shift 4
i=0
while [ "$i" -lt "$tries" ]; do
curl -s "$@" > "$file"
if grep -qF -- "$pat" "$file"; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc (попытка $((i + 1)))"
return 0
fi
i=$((i + 1))
sleep 1
done
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — условие не наступило за $tries с"
echo "--- последний ответ:"
cat "$file"
return 1
}
stop_app() {
if [ -n "${1:-}" ] && kill -0 "$1" 2>/dev/null; then
kill "$1" 2>/dev/null
sleep 2
if netstat -ano 2>/dev/null | grep ':5080' | grep -qi listening; then
taskkill //F //PID "$1" 2>/dev/null
sleep 1
fi
fi
echo " [INFO] Deal.Api остановлен"
}
psql_clear_task13() {
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"QueueItems\";" >/dev/null 2>&1
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"RejectedItems\";" >/dev/null 2>&1
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"DedupEntries\";" >/dev/null 2>&1
# Карточки приёмки Task 13 (dialogId t13_* и demo_channel) — повторяемость между прогонами.
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Cards\" WHERE \"SourceDialogId\" LIKE 't13\_%' OR \"SourceDialogId\" = 'demo_channel';" >/dev/null 2>&1
# Сброс настроек, которые трогает приёмка (к дефолтам модуля), если приёмка прервана.
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"settings\" WHERE \"Key\" IN ('stopPhrases','budgetRequiredHire','budgetRequiredOrder','wantedType');" >/dev/null 2>&1
}
cleanup() {
echo
echo "== Завершение (trap): остановка процессов и очистка строк/карточек/настроек приёмки =="
if [ -n "$SSE_PID" ] && kill -0 "$SSE_PID" 2>/dev/null; then
kill "$SSE_PID" 2>/dev/null
fi
stop_app "$APP_PID"
psql_clear_task13
rm -rf "$WORK"
}
trap cleanup EXIT INT TERM
rm -rf "$WORK"
mkdir -p "$BODY_DIR"
echo "== 0. Очистка pipeline-таблиц/карточек t13_*+demo_channel дефолтного тенанта (повторяемость) =="
PID_5080=$(netstat -ano 2>/dev/null | grep ':5080' | grep -i listening | awk '{print $NF}' | head -1)
if [ -n "$PID_5080" ]; then
echo " [WARN] порт 5080 занят pid $PID_5080 — останавливаю"
taskkill //F //PID "$PID_5080" >/dev/null 2>&1
sleep 1
fi
psql_clear_task13
echo
echo "== 0a. Запуск Deal.Api на :5080 с DEAL_DEMO=1 (Development) =="
cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development DEAL_DEMO=1 "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$!
i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1))
if [ "$i" -ge 45 ]; then
echo " [FAIL] сервер не поднялся за 45 с (лог: $LOG)"
tail -n 30 "$LOG"
exit 1
fi
sleep 1
done
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
echo
echo "== 1. 401 без сессии: /api/pipeline/* и /api/demo/ingest =="
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/pipeline/stats" > "$OUT"
check "GET /pipeline/stats без куки → 401" '[HTTP:401]' 'Требуется авторизация'
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/pipeline/rejected" > "$OUT"
check "GET /pipeline/rejected без куки → 401" '[HTTP:401]' 'Требуется авторизация'
echo
echo "== 2. Login admin/admin; stats/queue/rejected на старте — нули =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
check "login 200 ok" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/pipeline/stats" > "$OUT"
check "stats 200: пустая форма {queue:{new,ai,total}, rejected:0}" '[HTTP:200]' '"queue":{' '"new":0' '"ai":0' '"total":0' '"rejected":0'
curl -s -b "$JAR" "$BASE_URL/api/pipeline/queue?limit=120" > "$OUT"
check "queue: пусто (items [], total 0)" '"items":[]' '"total":0'
curl -s -b "$JAR" "$BASE_URL/api/pipeline/rejected" > "$OUT"
check "rejected: пусто (items [], total 0)" '"items":[]' '"total":0'
echo
echo "== 3. Настройки сценария: stopPhrases=['взаимный пиар'] (детерминированный отсев правил) =="
cat > "$BODY_DIR/patch_stop.json" <<'EOF'
{"stopPhrases":["взаимный пиар"]}
EOF
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" --data @"$BODY_DIR/patch_stop.json" > "$OUT"
check "PATCH settings 200: stopPhrases применён" '[HTTP:200]' '"stopPhrases":["взаимный пиар"]'
echo
echo "== 4. demo/ingest вакансии (demo_channel, msgId 40001) → очередь 1; повтор dialog+msgId → id:null =="
cat > "$BODY_DIR/ingest_vacancy.json" <<'EOF'
{"text":"Вакансия: Middle Python разработчик, удалённо\nСтек: Python, FastAPI\nБюджет: 1600-2200$\nКонтакты: @crm_head","dialogId":"demo_channel","channelName":"Демо-канал","channelHandle":"demo_channel","channelHue":"#0a7","msgId":40001}
EOF
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/demo/ingest" \
-H "Content-Type: application/json" --data @"$BODY_DIR/ingest_vacancy.json" > "$OUT"
check "ingest вакансии 200 {ok, id p_}" '[HTTP:200]' '"ok":true' '"id":"p_'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/demo/ingest" \
-H "Content-Type: application/json" --data @"$BODY_DIR/ingest_vacancy.json" > "$OUT"
check "повтор dialogId+msgId → гвард id:null" '[HTTP:200]' '"id":null'
echo
echo "== 5. tick → карточка вакансии в /leads (inbox) с полями §4.1 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
check "tick 200: форма {storage, reminders, pipeline, queue}" '[HTTP:200]' '"storage":{' '"reminders":[]' '"pipeline":{' '"queue":0'
wait_for "карточка demo_channel появилась в /leads" "$OUT" '"sourceDialogId":"demo_channel"' 10 -b "$JAR" "$BASE_URL/api/leads"
check "карточка в «Неразобранном»" '"col":"inbox"'
check "карточка: id l_ + заголовок из первой строки сообщения" '"id":"l_' '"title":"Вакансия: Middle Python'
check "карточка: «О заявке» (summary) и стек из метки «Стек:»" '"summary":"Вакансия: Middle Python' '"stack":["Python","FastAPI"]'
check "карточка: бюджет 1600-2200 USD (метка «Бюджет:») + конверсия в RUB" '"budget":{"from":1600,"to":2200,"cur":"USD"}' '"converted":{"from":' '"cur":"RUB"'
check "карточка: контакт + канал + исходник" '"contact":"@crm_head"' '"name":"Демо-канал"' '"sourceMsg":"Вакансия: Middle Python'
check "карточка: тип (маркерная гипотеза ИИ-пути — известен)" '"isVacancy":true' '"isVacancyKnown":true'
echo
echo "== 6. Отсев правил: короткий текст / стоп-фраза / резюме (3 записи) =="
cat > "$BODY_DIR/ingest_short.json" <<'EOF'
{"text":"Привет! Как дела?","dialogId":"t13_short","channelName":"T13-Канал","channelHandle":"t13_short","channelHue":"#999","msgId":40002}
EOF
cat > "$BODY_DIR/ingest_stop.json" <<'EOF'
{"text":"Предлагаю взаимный пиар: разместим посты друг друга бесплатно, подпишемся взаимно.","dialogId":"t13_stop","channelName":"T13-Канал","channelHandle":"t13_stop","channelHue":"#a00","msgId":40003}
EOF
cat > "$BODY_DIR/ingest_resume.json" <<'EOF'
{"text":"Моё резюме: Senior QA-инженер, 7 лет в тестировании продуктовых команд, удалённая занятость, зарплата от 3000$","dialogId":"t13_resume","channelName":"T13-Канал","channelHandle":"t13_resume","channelHue":"#b00","msgId":40004}
EOF
curl -s -b "$JAR" -X POST "$BASE_URL/api/demo/ingest" -H "Content-Type: application/json" --data @"$BODY_DIR/ingest_short.json" > "$OUT"
check "ingest короткого текста 200" '"ok":true'
curl -s -b "$JAR" -X POST "$BASE_URL/api/demo/ingest" -H "Content-Type: application/json" --data @"$BODY_DIR/ingest_stop.json" > "$OUT"
check "ingest стоп-фразы 200" '"ok":true'
curl -s -b "$JAR" -X POST "$BASE_URL/api/demo/ingest" -H "Content-Type: application/json" --data @"$BODY_DIR/ingest_resume.json" > "$OUT"
check "ingest резюме 200" '"ok":true'
curl -s -b "$JAR" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
curl -s -b "$JAR" "$BASE_URL/api/pipeline/rejected" > "$OUT"
check "rejected: короткое (правила, length)" '"id":"r_t13_short_40002"' '"stageLabel":"короткое сообщение"' '"sourceLabel":"правила"' '"reason":"короче 24 символов"'
check "rejected: стоп-фраза c kw (правила, stop)" '"id":"r_t13_stop_40003"' '"stageLabel":"стоп-фраза"' '"kw":"взаимный пиар"'
check "rejected: резюме соискателя (правила, resume)" '"id":"r_t13_resume_40004"' '"stageLabel":"резюме соискателя"' '"reason":"резюме соискателя («резюме»)"' '"kw":"резюме"'
echo
echo "== 7. Отсев dup: повторный ingest текста вакансии (другой dialog/msgId) → «повтор» (система) =="
cat > "$BODY_DIR/ingest_dup.json" <<'EOF'
{"text":"Вакансия: Middle Python разработчик, удалённо\nСтек: Python, FastAPI\nБюджет: 1600-2200$\nКонтакты: @crm_head","dialogId":"t13_dup","channelName":"T13-Канал","channelHandle":"t13_dup","channelHue":"#a00","msgId":40005}
EOF
curl -s -b "$JAR" -X POST "$BASE_URL/api/demo/ingest" -H "Content-Type: application/json" --data @"$BODY_DIR/ingest_dup.json" > "$OUT"
check "ingest дубля текста 200" '"ok":true'
curl -s -b "$JAR" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
curl -s -b "$JAR" "$BASE_URL/api/pipeline/rejected" > "$OUT"
check "rejected: dup (повтор, система, причина про карточку)" '"id":"r_t13_dup_40005"' '"stageLabel":"повтор"' '"sourceLabel":"система"' '"reason":"сообщение уже в системе: карточка создана ранее или этот текст уже обрабатывается"'
echo
echo "== 8. Отсев «нет суммы»: budgetRequiredHire=true + вакансия без бюджета =="
cat > "$BODY_DIR/patch_budget_on.json" <<'EOF'
{"budgetRequiredHire":true}
EOF
cat > "$BODY_DIR/patch_budget_off.json" <<'EOF'
{"budgetRequiredHire":false}
EOF
curl -s -b "$JAR" -X PATCH "$BASE_URL/api/settings" -H "Content-Type: application/json" \
--data @"$BODY_DIR/patch_budget_on.json" > "$OUT"
check "PATCH budgetRequiredHire=true" '"budgetRequiredHire":true'
cat > "$BODY_DIR/ingest_nobudget.json" <<'EOF'
{"text":"Вакансия: Senior Java разработчик на полную занятость, официальное оформление, офис в Москве, команда крупного банка, релокация не требуется","dialogId":"t13_nobudget","channelName":"T13-Канал","channelHandle":"t13_nobudget","channelHue":"#c00","msgId":40006}
EOF
curl -s -b "$JAR" -X POST "$BASE_URL/api/demo/ingest" -H "Content-Type: application/json" --data @"$BODY_DIR/ingest_nobudget.json" > "$OUT"
check "ingest вакансии без суммы 200" '"ok":true'
curl -s -b "$JAR" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
curl -s -b "$JAR" "$BASE_URL/api/pipeline/rejected" > "$OUT"
check "rejected: «нет суммы» (правила, budget)" '"id":"r_t13_nobudget_40006"' '"stageLabel":"нет суммы"' '"sourceLabel":"правила"' '"reason":"включён фильтр «не создавать карточку без суммы» — в тексте не указан бюджет"'
curl -s -b "$JAR" -X PATCH "$BASE_URL/api/settings" -H "Content-Type: application/json" \
--data @"$BODY_DIR/patch_budget_off.json" > "$OUT"
check "PATCH budgetRequiredHire=false (снят)" '"budgetRequiredHire":false'
echo
echo "== 9. Отсев «устарело»: msgAt старше 20 дн. (архив-срок 14 дн.) → карточки нет =="
STALE_MS=$((($(date +%s) - 1728000) * 1000))
cat > "$BODY_DIR/ingest_stale.json" <<EOF
{"text":"Ищу разработчика: текущий исполнитель занят другой работой и не может продолжать, нужен человек на 2 месяца, оплата 1800\$, детали в личные сообщения, контакт @stale_dev","dialogId":"t13_stale","channelName":"T13-Канал","channelHandle":"t13_stale","channelHue":"#d00","msgId":40007,"msgAt":$STALE_MS}
EOF
curl -s -b "$JAR" -X POST "$BASE_URL/api/demo/ingest" -H "Content-Type: application/json" --data @"$BODY_DIR/ingest_stale.json" > "$OUT"
check "ingest устаревшего сообщения 200" '"ok":true'
curl -s -b "$JAR" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
curl -s -b "$JAR" "$BASE_URL/api/pipeline/rejected" > "$OUT"
check "rejected: «устарело» (система, stale)" '"id":"r_t13_stale_40007"' '"stageLabel":"устарело"' '"sourceLabel":"система"' '"reason":"сообщение старше 14 дн. (срок до автоархива) — не заводим в систему"'
curl -s -b "$JAR" "$BASE_URL/api/leads" > "$OUT"
check_absent "leads: карточки t13_stale НЕТ (устаревшее не заводим)" '"sourceDialogId":"t13_stale"'
echo
echo "== 10. Итог фазы: очередь пуста, stats, отсев — 6 реальных записей (правила×3 + dup + нет суммы + устарело) =="
curl -s -b "$JAR" "$BASE_URL/api/pipeline/queue?limit=120" > "$OUT"
check "queue: после обработки пусто (items [], total 0)" '"items":[]' '"total":0' '"rejected":6'
curl -s -b "$JAR" "$BASE_URL/api/pipeline/stats" > "$OUT"
check "stats: очередь 0, отсев 6" '"queue":{' '"new":0' '"total":0' '"rejected":6'
curl -s -b "$JAR" "$BASE_URL/api/pipeline/rejected" > "$OUT"
n=$(grep -o '"id":"r_t13_[a-z0-9_]*"' "$OUT" | wc -l | tr -d ' ')
if [ "$n" = "6" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] rejected: ровно 6 записей r_t13_* в странице"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] rejected: ожидалось 6 записей, найдено $n"
echo "--- ответ:"; cat "$OUT"
fi
echo
echo "== 11. psql: QueueItems=0, RejectedItems=6, DedupEntries=1 (LeadId=карточка), SearchTsv карточек =="
$PSQL_BASE -t -A -c "SELECT count(*) FROM \"$SCHEMA\".\"QueueItems\";" > "$OUT"
if grep -qF '0' "$OUT"; then PASS_COUNT=$((PASS_COUNT + 1)); echo " [PASS] psql: QueueItems = 0"; else FAIL_COUNT=$((FAIL_COUNT + 1)); echo " [FAIL] psql: QueueItems не 0"; cat "$OUT"; fi
$PSQL_BASE -t -A -c "SELECT count(*) FROM \"$SCHEMA\".\"RejectedItems\";" > "$OUT"
if grep -qF '6' "$OUT"; then PASS_COUNT=$((PASS_COUNT + 1)); echo " [PASS] psql: RejectedItems = 6"; else FAIL_COUNT=$((FAIL_COUNT + 1)); echo " [FAIL] psql: RejectedItems не 6"; cat "$OUT"; fi
$PSQL_BASE -t -A -c "SELECT count(*) FROM \"$SCHEMA\".\"DedupEntries\";" > "$OUT"
if grep -qF '1' "$OUT"; then PASS_COUNT=$((PASS_COUNT + 1)); echo " [PASS] psql: DedupEntries = 1 (claim вакансии)"; else FAIL_COUNT=$((FAIL_COUNT + 1)); echo " [FAIL] psql: DedupEntries не 1"; cat "$OUT"; fi
$PSQL_BASE -t -A -c "SELECT count(*) FROM \"$SCHEMA\".\"DedupEntries\" d JOIN \"$SCHEMA\".\"Cards\" c ON c.\"Id\" = d.\"LeadId\" WHERE c.\"SourceDialogId\" = 'demo_channel';" > "$OUT"
if grep -qF '1' "$OUT"; then PASS_COUNT=$((PASS_COUNT + 1)); echo " [PASS] psql: DedupEntries.LeadId связан с карточкой demo_channel"; else FAIL_COUNT=$((FAIL_COUNT + 1)); echo " [FAIL] psql: dedup-связи с карточкой нет"; cat "$OUT"; fi
$PSQL_BASE -t -A -c "SELECT (\"SearchTsv\"::text LIKE '%fastapi%') FROM \"$SCHEMA\".\"Cards\" WHERE \"SourceDialogId\" = 'demo_channel';" > "$OUT"
if grep -qF 't' "$OUT"; then PASS_COUNT=$((PASS_COUNT + 1)); echo " [PASS] psql: SearchTsv карточки заполнен (лексема fastapi)"; else FAIL_COUNT=$((FAIL_COUNT + 1)); echo " [FAIL] psql: SearchTsv карточки пуст/без fastapi"; cat "$OUT"; fi
echo
echo "== 12. GET /pipeline/rejected?q= — поиск по реальным записям (FTS LIKE, Ruling 6) =="
curl -s -b "$JAR" "$BASE_URL/api/pipeline/rejected?q=T13" > "$OUT"
check "q=T13 (LIKE по имени канала) → все 6 записей канала T13-Канал" '"total":6' '"id":"r_t13_stop_40003"'
# q=работа — в тексте stale-записи форма «работой»: подстроки «работа» нет → находит ТОЛЬКО tsvector (FTS).
curl -s -b "$JAR" "$BASE_URL/api/pipeline/rejected?q=%D1%80%D0%B0%D0%B1%D0%BE%D1%82%D0%B0" > "$OUT"
check "q=работа (FTS-морфология по «работой») → запись r_t13_stale_40007" '"total":1' '"id":"r_t13_stale_40007"'
curl -s -b "$JAR" "$BASE_URL/api/pipeline/rejected?q=%D0%B2%D0%B7%D0%B0%D0%B8%D0%BC%D0%BD%D1%8B%D0%B9" > "$OUT"
check "q=взаимный (FTS∪LIKE) → стоп-запись" '"total":1' '"id":"r_t13_stop_40003"'
echo
echo "== 13. POST /pipeline/rejected/{id}/return на реальных записях: dup → 400; stop → в очередь =="
cat > "$BODY_DIR/return_dup.json" <<'EOF'
{"reason":"проверка dup-ветки"}
EOF
cat > "$BODY_DIR/return_stop.json" <<'EOF'
{"reason":"оператор вернул из отсева"}
EOF
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/pipeline/rejected/r_t13_dup_40005/return" \
-H "Content-Type: application/json" --data @"$BODY_DIR/return_dup.json" > "$OUT"
check "return дубля → 400 «Повтор: карточка … уже в системе»" '[HTTP:400]' '"detail":"Повтор: карточка с таким текстом уже есть в системе — возвращать нечего"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/pipeline/rejected/r_t13_stop_40003/return" \
-H "Content-Type: application/json" --data @"$BODY_DIR/return_stop.json" > "$OUT"
check "return стоп-фразы → 200 {id, returned:true, returnedAt}" '[HTTP:200]' '"id":"r_t13_stop_40003"' '"returned":true' '"returnedAt":'
curl -s -b "$JAR" "$BASE_URL/api/pipeline/queue?limit=120" > "$OUT"
check "queue: возвращённое сообщение в очереди (total=1)" '"new":1' '"total":1'
curl -s -b "$JAR" "$BASE_URL/api/pipeline/rejected" > "$OUT"
check "rejected: запись stop помечена returned (аудит, не удалена)" '"id":"r_t13_stop_40003"' '"returned":true' '"returnReason":"оператор вернул из отсева"'
echo
echo "== 14. tick → возвращённое (force) обработано: карточка t13_stop создана, очередь пуста =="
curl -s -b "$JAR" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
check "tick после return: queue 0" '"queue":0'
wait_for "карточка t13_stop появилась в /leads (force-возврат → карточка)" "$OUT" '"sourceDialogId":"t13_stop"' 10 -b "$JAR" "$BASE_URL/api/leads"
check "карточка t13_stop в inbox" '"col":"inbox"'
curl -s -b "$JAR" "$BASE_URL/api/pipeline/queue?limit=120" > "$OUT"
check "queue: снова пусто после pump" '"total":0'
echo
echo "== 15. Повторный return той же записи → 400 «уже возвращено»; DELETE записи; clear =="
cat > "$BODY_DIR/return_again.json" <<'EOF'
{"reason":"ещё раз"}
EOF
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/pipeline/rejected/r_t13_stop_40003/return" \
-H "Content-Type: application/json" --data @"$BODY_DIR/return_again.json" > "$OUT"
check "повторный return → 400 «Сообщение уже возвращено в обработку»" '[HTTP:400]' '"detail":"Сообщение уже возвращено в обработку"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X DELETE "$BASE_URL/api/pipeline/rejected/r_t13_resume_40004" > "$OUT"
check "DELETE /rejected/{id} → {ok:true}" '[HTTP:200]' '"ok":true'
curl -s -b "$JAR" "$BASE_URL/api/pipeline/rejected" > "$OUT"
check_absent "rejected: запись резюме удалена" '"id":"r_t13_resume_40004"'
n=$(grep -o '"id":"r_t13_[a-z0-9_]*"' "$OUT" | wc -l | tr -d ' ')
if [ "$n" = "5" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] после DELETE осталось 5 записей"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] после DELETE ожидалось 5 записей, найдено $n"
echo "--- ответ:"; cat "$OUT"
fi
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/pipeline/rejected/clear" > "$OUT"
check "clear → {ok, cleared:5}" '[HTTP:200]' '"ok":true' '"cleared":5'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/pipeline/rejected/clear" > "$OUT"
check "повторный clear → {ok, cleared:0}" '[HTTP:200]' '"ok":true' '"cleared":0'
curl -s -b "$JAR" "$BASE_URL/api/pipeline/rejected" > "$OUT"
check "rejected: пусто после clear" '"items":[]' '"total":0'
echo
echo "== 16. FTS-поиск карточек: /api/search по созданным (q=fastapi → вакансия; q=пиар → возврат-карточка) =="
curl -s -G -b "$JAR" "$BASE_URL/api/search" --data-urlencode "q=fastapi" > "$OUT"
check "q=fastapi: нашлась карточка вакансии (demo_channel)" '"sourceDialogId":"demo_channel"' '"messages":[]'
curl -s -b "$JAR" "$BASE_URL/api/search?q=%D0%BF%D0%B8%D0%B0%D1%80" > "$OUT"
check "q=пиар: нашлась карточка из возврата (t13_stop)" '"sourceDialogId":"t13_stop"' '"messages":[]'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/fts/rebuild" > "$OUT"
check "POST /admin/fts/rebuild → {ok:true, ready:true}" '[HTTP:200]' '"ok":true' '"ready":true'
echo
echo "== 17. psql: карточка ↔ dedup; DELETE /leads/{id} чистит DedupEntries =="
LEAD_ID=$($PSQL_BASE -t -A -c "SELECT \"Id\" FROM \"$SCHEMA\".\"Cards\" WHERE \"SourceDialogId\" = 'demo_channel' LIMIT 1;" | tr -d '[:space:]')
if [ -n "$LEAD_ID" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] psql: карточка demo_channel найдена ($LEAD_ID)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] psql: карточка demo_channel не найдена"
fi
$PSQL_BASE -t -A -c "SELECT count(*) FROM \"$SCHEMA\".\"DedupEntries\" WHERE \"LeadId\" = '$LEAD_ID';" > "$OUT"
if grep -qF '1' "$OUT"; then PASS_COUNT=$((PASS_COUNT + 1)); echo " [PASS] psql: dedup-строка LeadId=$LEAD_ID есть"; else FAIL_COUNT=$((FAIL_COUNT + 1)); echo " [FAIL] psql: dedup-строки LeadId нет"; cat "$OUT"; fi
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X DELETE "$BASE_URL/api/leads/$LEAD_ID" > "$OUT"
check "DELETE /leads/{id} → {ok:true}" '[HTTP:200]' '"ok":true'
$PSQL_BASE -t -A -c "SELECT count(*) FROM \"$SCHEMA\".\"DedupEntries\" WHERE \"LeadId\" = '$LEAD_ID';" > "$OUT"
if grep -qF '0' "$OUT"; then PASS_COUNT=$((PASS_COUNT + 1)); echo " [PASS] psql: DedupEntries очищены при удалении карточки (0)"; else FAIL_COUNT=$((FAIL_COUNT + 1)); echo " [FAIL] psql: DedupEntries не очищены"; cat "$OUT"; fi
echo
echo "== 18. Автоочистка отсева 3 дн. на реальной записи: ingest стоп-фразы → tick → rejected 1 =="
cat > "$BODY_DIR/ingest_purge.json" <<'EOF'
{"text":"Давайте сделаем взаимный пиар: обменяемся постами друг друга и подписками","dialogId":"t13_purge","channelName":"T13-Канал","channelHandle":"t13_purge","channelHue":"#a00","msgId":40008}
EOF
curl -s -b "$JAR" -X POST "$BASE_URL/api/demo/ingest" -H "Content-Type: application/json" --data @"$BODY_DIR/ingest_purge.json" > "$OUT"
check "ingest стоп-фразы t13_purge 200" '"ok":true'
curl -s -b "$JAR" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
curl -s -b "$JAR" "$BASE_URL/api/pipeline/rejected" > "$OUT"
check "rejected: запись t13_purge (стоп-фраза)" '"id":"r_t13_purge_40008"' '"total":1'
echo
echo "== 19. SSE-подписка → состариваем RejectedAt (4 дн.) → tick: purge + тост «Отсев очищен» =="
curl -s -N -b "$JAR" "$BASE_URL/api/events" > "$SSE_LOG" 2>&1 &
SSE_PID=$!
sleep 2
$PSQL_BASE -c "UPDATE \"$SCHEMA\".\"RejectedItems\" SET \"RejectedAt\" = now() - interval '4 days';" >/dev/null 2>&1
echo " [INFO] RejectedAt записи t13_purge состарено на 4 дня; зовём tick..."
curl -s -b "$JAR" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
check "tick 200: purge отсева → storage.purgedRejected=1" '"purgedRejected":1'
i=0
while [ "$i" -lt 10 ]; do
if grep -qF "Отсев очищен: 1 записей (3 дн.)" "$SSE_LOG"; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] SSE-тост «Отсев очищен: 1 записей (3 дн.)» пришёл подписчику (попытка $((i + 1)))"
break
fi
i=$((i + 1))
sleep 1
done
if [ "$i" -ge 10 ]; then
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] SSE-тост очистки отсева не пришёл за 10 с"
echo "--- sse.log:"; cat "$SSE_LOG"
fi
curl -s -b "$JAR" "$BASE_URL/api/pipeline/rejected" > "$OUT"
check "rejected: очищено (total 0)" '"items":[]' '"total":0'
kill "$SSE_PID" 2>/dev/null
SSE_PID=""
echo
echo "== 20. Logout → 401 без куки =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
check "logout 200 ok" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/pipeline/stats" > "$OUT"
check "GET /pipeline/stats после logout → 401" '[HTTP:401]' 'Требуется авторизация'
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/pipeline/rejected" > "$OUT"
check "GET /pipeline/rejected после logout → 401" '[HTTP:401]' 'Требуется авторизация'
curl -s -w "\n[HTTP:%{http_code}]" -X POST "$BASE_URL/api/demo/ingest" \
-H "Content-Type: application/json" --data @"$BODY_DIR/ingest_stop.json" > "$OUT"
check "POST /api/demo/ingest после logout → 401" '[HTTP:401]' 'Требуется авторизация'
echo
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
stop_app "$APP_PID"
APP_PID=""
psql_clear_task13
if [ "$FAIL_COUNT" -gt 0 ]; then
exit 1
fi
exit 0
@@ -0,0 +1,94 @@
# Task 13 — «Финал этапа — интеграция и сквозная приёмка» — отчёт
Статус: **DONE (review pending)**. Сборка 0 warnings / 0 errors (`dotnet build Deal.sln`,
`sh scripts/build.sh`); unit-тесты **535/535 PASS** (`dotnet test Deal.sln`, `sh scripts/test.sh`);
сквозная curl-приёмка на :5080 (DEAL_DEMO=1, admin/admin) — **74/74 PASS** (скрипт
`task-13-curl-acceptance.sh`, лог `task-13-curl-acceptance.log`). План:
`docs/superpowers/plans/2026-09-05-deal-stage4-pipeline.md` Task 13 (L519542) + Self-Review;
Rulings 2/6/8/9/10/11; закрыт T9-concern («return/dup-400/DELETE/clear/q-FTSLIKE на реальных
записях отсева» — ранее эндпоинты проверялись на пустом отсеве, Task 9 report).
## Артефакты приёмки
- `task-13-curl-acceptance.sh` — один сквозной сценарий (все шаги ниже, PASS/FAIL каждого шага);
- `task-13-curl-acceptance.log` — прогон: `== Итог: PASS=74 FAIL=0 ==` (exit 0).
## Сценарий (что реально проверено на :5080)
1. 401 без куки (stats/rejected) → login → **нули**: stats `{queue:{new:0,ai:0,total:0},
rejected:0}`, queue `items:[] total:0`, rejected пуст.
2. PATCH settings `stopPhrases=["взаимный пиар"]` (детерминированный отсев правил).
3. **demo/ingest вакансии** (demo_channel, msgId 40001; текст с метками «Стек:/Бюджет:/Контакты:») →
повтор `dialogId+msgId` → `id:null` (гвард) → tick → **карточка** `l_…` в /leads: col inbox,
title из первой строки, summary («О заявке»), `stack:["Python","FastAPI"]` из метки,
`budget {1600,2200,USD}` + `converted RUB`, `contact @crm_head`, ch «Демо-канал», sourceMsg,
`isVacancy/isVacancyKnown` (стемп ИИ-пути).
4. **Отсев правил** (реальные записи): короткий текст → stage `length` («короткое сообщение»,
«короче 24 символов»); стоп-фраза → stage `stop`, kw «взаимный пиар»; «Моё резюме: …» →
stage `resume` («резюме соискателя», kw «резюме»). Всё `source=stop`/«правила».
5. **Отсев dup** — повторный ingest того же текста вакансии (другой dialog/msgId): stage `dup`,
«повтор», источник «система», причина про «карточку… уже обрабатывается».
6. **«Нет суммы»** — PATCH `budgetRequiredHire=true` + вакансия без бюджета → stage `budget`
(«нет суммы»/правила), затем флаг снят.
7. **«Устарело»** — msgAt старше 20 дн. (срок 14 дн.) → stage `stale` («устарело»/система,
«сообщение старше 14 дн. …»); карточка НЕ создана.
8. Итог фазы: queue total 0; stats `rejected:6`; страница rejected — ровно 6 записей `r_t13_*`;
psql: QueueItems=0, RejectedItems=6, DedupEntries=1 **с LeadId=карточка вакансии**,
`Cards.SearchTsv` заполнен (лексема fastapi).
9. **Поиск отсева (FTS ∪ LIKE)** на реальных записях: `q=T13` → total 6 (LIKE по имени канала
«T13-Канал»); `q=работа` → total 1 (FTS-морфология: в тексте «работой», подстроки «работа» нет —
находит только tsvector); `q=взаимный` → стоп-запись.
10. **return**: дубля → **400** «Повтор: карточка с таким текстом уже есть в системе…»;
стоп-записи → 200 `{id, returned:true, returnedAt}`, очередь `total=1`, запись помечена
returned + returnReason (аудит, не удалена); tick → **карточка из возврата** (sourceDialogId
t13_stop) создана (force-путь), очередь пуста; **повторный return** той же записи → 400
«Сообщение уже возвращено в обработку».
11. **DELETE** /rejected/{resume} → ok (запись ушла, осталось 5); **clear** → `{ok, cleared:5}`,
повторный clear → `cleared:0`; rejected пуст.
12. **FTS-поиск карточек**: `q=fastapi` → карточка вакансии; `q=пиар` → карточка из возврата;
`messages:[]`; `POST /admin/fts/rebuild` → `{ok:true, ready:true}`.
13. **psql: карточка ↔ dedup; DELETE /leads/{id} чистит DedupEntries** (было 1 → стало 0).
14. **Автоочистка отсева 3 дн.**: ingest стоп-фразы → rejected 1 → psql состарил RejectedAt (4 дн.)
→ подписанный SSE + tick → `storage.purgedRejected=1`, **SSE-тост «Отсев очищен: 1 записей
(3 дн.)»**, rejected total 0.
15. logout → 401 (stats, rejected, demo/ingest). После прогона — приложение остановлено, dev-БД
очищена (QueueItems/RejectedItems/DedupEntries = 0, карточек 0, настройки сброшены к дефолтам;
схемы/таблицы/индексы на месте).
## Что сделано (кроме кода — кода не менялось)
- Техдок `docs/technical/Техническая-документация-Дейл.md`: §13 — заголовок/интро на этап 4,
добавлен §4d «Эндпоинты этапа 4 (pipeline/„Обработка")» (таблицы TenantPipeline, demo-ingest,
воркер-цикл 2 с, конвейер отсева, эндпоинты /pipeline + return/clear, FTS отсева и /api/search,
автоочистка 3 дня, SSE-политика, admin/tick + fts/rebuild), примечание в §4c, §5 (psql-ожидания:
QueueItems/RejectedItems/DedupEntries/SearchTsv), §6 (535 PASS, финальная curl-приёмка 74/74);
§11 — блок «Выполнено на этапе 4» + актуализированы TODO (FTS/rebuild больше не заглушки,
канбан работает на реальном конвейере).
- Roadmap `docs/superpowers/plans/2026-09-05-deal-roadmap.md`: этап 4 перенесён в «Выполнено»
(задачи 113, 535 PASS, PASS=74 FAIL=0; ограничения: projects/reminder_due/файлы — этап 5,
реальные ai/telegram/ml + gRPC-ингресс, discovery — этап 6, оператор/лимиты/аудит — этап 7),
заголовок «актуально на конец этапа 4», из «Оставшихся этапов» блок этапа 4 удалён.
- Ledger `.superpowers/sdd/deal-stage4-pipeline/progress.md`: Task 13 complete + `[x]`.
## Находки/решения приёмки
- **Кириллица в inline `-d` curl на Windows/git-bash битая** (известный квирк этапов ранее): все тела с
кириллицей — через файлы (`--data @файл`); поисковые q с кириллицей — percent-кодированным UTF-8 в
URL. Это же касается `--data-urlencode` (argv искажается).
- **q-морфология отсева**: для честного FTS-доказательства (без LIKE) остальные тексты приёмки не
содержат слова-основы «работ*» — q=работа находит stale-запись «работой» только tsvector'ом.
- **Конверсия карточки** зависит от `ratesCache` тенанта (в dev-БД реальные курсы ЦБ, не мок) —
в приёмке конверсия проверяется структурно (`converted` + `cur:"RUB"`), а не точным числом.
- **Стек карточки детерминирован меткой «Стек: …»** на строке (fallback по однострочному тексту без
двоеточия стек не заполняет — поведение 1:1 с прототипом, подтверждено прогоном).
- Фоновый pump (2 с) и ручной tick работают параллельно; приёмочные счётчики очереди «сразу после
ingest» неустойчивы (может успеть фоновый цикл) — финальные состояния проверяются после tick/wait_for.
## Concerns для следующих этапов
- Отсевы spam_ml/spam_ai/filter_ai в сквозном сценарии недостижимы (локальные ML/ИИ pass) — ветки
покрыты unit-тестами (этап 6: реальные ai/ml-сервисы дадут живые данные).
- Приём входящих — только demo/ingest до gRPC-ингресса telegram-service (этап 6); контракт
`PipelineIngestService.EnqueueAsync` стабилен (Ruling 2).
- Dev-БД оставлена пустой (карточки/очередь/отсев = 0, настройки — дефолты); схемы/таблицы/индексы
TenantPipeline на месте — этап 5 может начинаться с чистого состояния.
@@ -0,0 +1,52 @@
# Task 2 — «Модуль Pipeline: DTO, порт IPipelineStore, реестр» — отчёт
Статус: **DONE** (build 0/0, тесты 410/410 PASS, модуль чистый, циклов нет).
## Файлы
### Созданы — DTO (`src/core/Deal.Modules.Pipeline/Application/Models/`, 1 тип = 1 файл, record'ы)
| Файл | Назначение | Поля (1:1 с §4.5 L308313 / команды Ruling 2) |
|---|---|---|
| `QueueItemDto.cs` | Элемент очереди GET /pipeline/queue | id/dialogId/msgId/text/status/ch{name,handle,hue}/msgAt/queuedAt (epoch-ms) + внутренний `Force` под `[JsonIgnore]` (в wire не выходит, как прототипная pipeline_msg.force) |
| `RejectedItemDto.cs` | Элемент отсева GET /pipeline/rejected | id/dialogId/msgId/text/stage/stageLabel/reason/kw/source/sourceLabel/ch/msgAt/rejectedAt/returned/returnedAt/returnReason (epoch-ms наружу) |
| `PipelineChannelDto.cs` | Объект `ch` очереди/отсева | name/handle/hue (общий тип для обоих списков, как inline-ch прототипа) |
| `QueueCountsDto.cs` | Счётчики `counts`/`queue` | new/ai/total (ai = статус filtered, bucket прототипа) |
| `QueuedMessage.cs` | Команда приёма (Ruling 2, enqueue L5385) | dialogId + плоские ch-поля + msgId/text/msgAt(epoch-ms, null→now)/force |
| `RejectRecord.cs` | Команда записи отсева (processing.record L66101) | dialog/msgId/text/ch/msgAt + source/stage/reason/kw; computed `DeterministicId` (`r_<dialog>_<msgId>`, null → случайный r_+hex в адаптере, Ruling 1) |
| `PipelinePumpResult.cs` | Результат pump (Ruling 8) | staged/rulesStored/mlStored/mlDrop/typeDrop/aiStored/aiDrop/aiFail/noBudget + `CreatedCards: IReadOnlyList<CardDto>` (Kanban, для SSE new_lead) |
### Созданы — Application
| Файл | Содержание |
|---|---|
| `IPipelineStore.cs` | Порт (детали ниже). |
| `PipelineRejectConstants.cs` | Словари 1:1 с processing.py L2646: stage→stageLabel (length→«короткое сообщение», …, dup→«повтор», 10), source→sourceLabel (stop→«правила», ml→«ML», ai→«ИИ», stale|dup→«система», 5); методы StageLabel/SourceLabel с фолбэками как prototype L4045; `RetentionDays = 3` (RETENTION_DAYS). |
| `PipelineIdPrefixes.cs` | `p_` (очередь), `r_` (отсев); dedup-хэш — SHA1 без префикса (комментарий). Генератор случайной части — общий `PrefixId` модуля Kanban (переиспользование; вынос в SharedKernel не потребовался — Kanban уже в зависимостях, дублирования нет). |
| `PipelineModuleRegistrar.cs` | `AddPipelineModule()` — каркас: пока пуст (сервисы появятся в задачах 4/5/7/8); XML-doc фиксирует границы (адаптер — Infrastructure/AddDealPersistence, IAiClassifier — AddDealIntegrations). |
### Изменён
- `Deal.Modules.Pipeline.csproj` — ProjectReference на `Deal.Modules.Settings` и `Deal.Modules.Kanban` + PackageReference `Microsoft.Extensions.DependencyInjection.Abstractions` 10.0.11 (как Kanban/Settings).
## Порт IPipelineStore — состав (сигнатуры на DTO + CancellationToken; только нужное задачам 3/5/8/9/11)
- **Очередь (QueueItems):** `ExistsDuplicateAsync(dialogId, msgId)` (дубль-гвард Ruling 2; msgId null → false), `AddAsync(QueueItemDto)` (id/статус/CreatedAt задаёт модуль), `ListAsync(string? status, int limit)` (CreatedAt ASC; статус-фильтр — для pump-батчей new=12/filtered=4, null — все для GET /queue), `CountByStatusAsync(status)`, `SetStatusAsync(id, status)` (UpdatedAt=now), `RemoveAsync(id)`.
- **Отсев (RejectedItems):** `UpsertAsync(RejectRecord)` (детерминированный id либо r_+hex, upsert ON CONFLICT, пустой текст — no-op), `ListPageAsync(offset, limit)` (RejectedAt DESC), `SearchIdsAsync(q, limitFts, limitLike)` (FTS-кандидаты rank DESC ∪ LIKE-дополнение по lower(text)/reason/kw/ch_name, без дублей — Ruling 6), `CountAsync()`, `GetAsync(id)`, `DeleteAsync(id)`, `ClearAsync()` → int, `PurgeExpiredAsync(olderThan: DateTimeOffset)` → int, `MarkReturnedAsync(id, reason, returnedAt)`.
- **Дедуп (DedupEntries):** `ExistsAsync(hash)`, `ClaimAsync(hash)` (INSERT ON CONFLICT DO NOTHING), `DeleteClaimAsync(hash)` (только LeadId IS NULL), `LinkAsync(hash, cardId)`, `DeleteByLeadAsync(cardId)` (для KanbanStore Ruling 3 — вызов из адаптера, без цикла модулей).
## Валидация
- `dotnet build Deal.sln` (src/core): 0 предупреждений / 0 ошибок.
- `dotnet test tests/Deal.Tests.Unit`: 410/410 PASS (MarkerTests 2/2 PASS).
- Чистота модуля: в `Deal.Modules.Pipeline/**/*.cs` нет `using`/кода EF (`Microsoft.EntityFrameworkCore`), Npgsql, `System.Net.Http`, `Deal.Infrastructure` (совпадения grep — только прозаические упоминания в XML-doc о том, что адаптер живёт в Infrastructure). Kanban/Settings на Pipeline не ссылаются — циклов нет.
- Плановое правило Ruling 3 соблюдено: Pipeline → Kanban (модель CardDto в PipelinePumpResult + будущие IKanjStore/ColumnRules) — однонаправленно.
## Отклонения и решения (в рамках плана, YAGNI)
- `ListAsync(limit)` из списка Task 2 уточнён до `ListAsync(string? status, int limit)`: pump-воркер (Task 8) выбирает батчи по статусам new/filtered раздельно (прототип `WHERE status=? ORDER BY created_at LIMIT ?`), GET /queue отдаёт все статусы (status=null). Без параметра статуса один метод не покрыл бы обе задачи.
- Queue/Rejects удаление по строковому id не могут сосуществовать как одноимённые перегрузки — удаление строки отсева названо `DeleteAsync` (зеркалит сервис/эндпоинт DELETE /rejected), строки очереди — `RemoveAsync` (как в списке Task 2).
- `Force` — внутренняя колонка строки очереди, которой нет в wire §4.5 (её читает воркер, Ruling 8): добавлена в `QueueItemDto` под `[JsonIgnore]` (в JSON не выходит; в код-базе уже есть прецедент `JsonPropertyName` на DTO CardDto). Альтернатива (отдельный row-DTO) плодила бы второй тип — отброшена.
- Детерминированный id отсева считает `RejectRecord.DeterministicId` (чистая логика модуля) — и адаптер (Task 3), и FakePipelineStore (Task 5) используют его без дублирования формата; случайный `r_`+hex — зона адаптера (как processing.record L77).
- Поля времини в DTO — long epoch-ms с `JsonPropertyName` (msgAt/queuedAt/rejectedAt/returnedAt), эталон CardDto.ReceivedAtMs; маппинг с DateTimeOffset-строками — в адаптере (Task 3).
- Статусы очереди (`new`/`filtered`) не вынесены в отдельный константный класс: в Task 2 их никто не использует (потребители появятся в 3/5/8) — значения зафиксированы в XML-doc порта.
@@ -0,0 +1,81 @@
# Task 3 — «EF-адаптер PipelineStore + DI + жёсткое удаление карточек (DedupEntries)» — отчёт
Статус: **DONE** (build 0/0, тесты 410/410 PASS, функциональный харнесс PipelineStore/KanbanStore на
дефолтном тенанте зелёный, psql-подтверждение чистки DedupEntries при DeleteForeverAsync — зелёное,
циклов модулей нет, схема тенанта возвращена в пустое состояние).
План: `docs/superpowers/plans/2026-09-05-deal-stage4-pipeline.md` Task 3 (L300325), Rulings 1/3/6;
эталоны `KanbanStore.cs`/`SettingsStore.cs`; фактический порт — `Deal.Modules.Pipeline/Application/IPipelineStore.cs` (Task 2).
## Файлы
### Создан
- `src/core/Deal.Infrastructure/Persistence/Repositories/PipelineStore.cs` — реализация `IPipelineStore` на
`TenantDbContext` (primary constructor, как `SettingsStore`/`KanbanStore`). Все 24 метода порта: очередь (6),
отсев (9), дедуп (5) + маппинг. EF-код — только здесь и в KanbanStore (Infrastructure).
### Изменены
- `src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.cs``DeleteForeverAsync`/`ClearColAsync`/
`PurgeAsync` дополнительно удаляют `DedupEntries WHERE LeadId = ?` (Ruling 3): каждая операция — в явной
транзакции (сначала dedup-строки, затем Cards), как `DeleteBoardAsync`. ClearColAsync чистит дедуп через
подзапрос по карточкам колонки; PurgeAsync — `LeadId IN (пачка id)`.
- `src/core/Deal.Modules.Kanban/Application/IKanjStore.cs` — XML-doc `DeleteForeverAsync`/`PurgeAsync`/
`ClearColAsync` + шапка порта: семантика «Cards + комментарии (FK cascade) + DedupEntries» (Ruling 3).
- `src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs``AddScoped<IPipelineStore, PipelineStore>()`
в `AddDealPersistence` (Ruling 10) + using модуля Pipeline.
- `src/core/Deal.Infrastructure/Deal.Infrastructure.csproj` — ProjectReference → `Deal.Modules.Pipeline`.
## Реализация PipelineStore
- **Чтения** — `AsNoTracking()`; сортировки/лимиты по порту: очередь `CreatedAt ASC` (+статус-фильтр pump),
отсев `RejectedAt DESC`; страницы — Skip/Take.
- **Маппинг вручную**: `ToQueueItemDto/ToQueueItemEntity/ToRejectedItemDto`; времена `timestamptz` ↔ epoch-ms
(`ToUnixTimeMilliseconds`/`FromUnixTimeMilliseconds`); подписи `stageLabel`/`sourceLabel` — через
`PipelineRejectConstants.StageLabel/SourceLabel` (1:1 processing L4045).
- **Raw SQL** там, где EF-выражений нет: `UpsertAsync``INSERT … ON CONFLICT ("Id") DO UPDATE SET …` 1:1 с
processing.record L79101 (обновляются только поля прототипа; аудит возврата returned/returnedAt/returnReason
при повторном отсеве переживает — как в прототипе). Детерминированный id `r_<dialog>_<msgId>` — из
`RejectRecord.DeterministicId`, иначе `PrefixId.New("r_")` (Ruling 1, processing L77). Пустой текст — no-op.
`ClaimAsync``INSERT … ON CONFLICT ("Hash") DO NOTHING` (Ruling 8, L947949).
**Нюанс**: колонки `Returned`/`ReturnReason` (NOT NULL, БЕЗ дефолтов БД — миграция Task 1 не задавала
HasDefaultValue) пишутся в INSERT явно `false`/`''`; иначе Postgres 23502 (прототип полагался на дефолты
SQLite). `SearchTsv` (computed STORED) в INSERT не входит.
- **FTS-поиск** (`SearchIdsAsync`): кандидаты — `FromSqlInterpolated` `SearchTsv @@ plainto_tsquery('russian', q)`
с `ts_rank DESC, RejectedAt DESC LIMIT limitFts` (Ruling 6; plainto_tsquery со стоп-словами даёт пустой
tsquery — не ошибка, проверено psql); LIKE-дополнение — `EF.Functions.Like(lower(Text/Reason/Kw/ChannelName),
%q%)`, `RejectedAt DESC LIMIT limitLike`; объединение без дублей (HashSet), FTS-кандидаты первыми (порт Task 2).
- **Одиночные statement'ы** для остального: `ExecuteUpdateAsync` (SetStatus/MarkReturned/Link),
`ExecuteDeleteAsync` (Remove/Delete/Clear/PurgeExpired/DeleteClaim/DeleteByLead) — возврат числа удалённых
у очисток. Явные транзакции не нужны (многошаговых операций в порте нет).
- **DeleteByLeadAsync** реализован (порт), но KanbanStore при удалениях НЕ вызывает порт Pipeline (это создало
бы Kanban→Pipeline): Kanban-адаптер делает тот же `DELETE … WHERE LeadId = ?` напрямую своим
`TenantDbContext` (Ruling 3, плановый механизм без колбэков/интерфейсов чистки) — цикла модулей нет
(Kanban о Pipeline не знает: csproj без ссылки, grep по модулю — пусто).
## Проверка
1. **Build**: `dotnet build Deal.sln` — Ошибок: 0, Предупреждений: 0 (TreatWarningsAsErrors).
2. **Тесты**: `dotnet test tests/Deal.Tests.Unit` — 410/410 PASS (unit на EF-адаптер планом не требуются).
3. **Функциональный харнесс** (временный проект вне sln, удалён после прогона; схема
`tenant_00000000000000000000000000000001`, dev-Postgres :5433) — 48/48 проверок ok:
- очередь: дубль-гвард по DialogId+MsgId до/после вставки, Add/List(+статус-фильтр)/счётчики/SetStatus/
Remove, маппинг ch/msgAt/queuedAt;
- отсев: upsert дважды с тем же dialog+msgId → ОДНА строка `r_d_t3h_55` с обновлёнными полями (приёмка
плана), подписи, случайный `r_`+hex при отсутствии dialog+msgId, FTS-кандидат (plainto_tsquery),
LIKE-дополнение по ch_name, PurgeExpired по границе, MarkReturned, Delete, Clear;
- дедуп: Claim (в т.ч. повторный — DO NOTHING), DeleteClaim (только LeadId IS NULL), карточка → Link →
**DeleteForeverAsync удаляет карточку и её DedupEntries**; PurgeAsync пачки и ClearColAsync корзины —
тоже снимают дедуп-строки; после прогона все таблицы сценария пусты.
4. **psql** (docker exec deal-postgres): до удаления `DedupEntries` (Hash=`t3h_psql_hash`, LeadId=`l_t3h_psql`)
+ `Cards` (l_t3h_psql, col=inbox) присутствуют; после `DeleteForeverAsync` обе выборки — 0 строк;
финальные счётчики QueueItems/RejectedItems/DedupEntries/Cards — 0 (схема в исходном пустом состоянии).
5. Диагностики изменённых C#-файлов — без ошибок/предупреждений (проектные C#-диагностики чисты).
## Чистота / границы
- Единственные места EF-кода новых таблиц — `PipelineStore` + `KanbanStore` (Infrastructure); модуль Pipeline
чист (EF не знает); Kanban на Pipeline не ссылается (csproj + grep), Pipeline → Kanban (PrefixId, чистые
помощники, порт IKanjStore) — разрешённое однонаправление. Циклов нет.
- Стиль: 1 тип = 1 файл, XML-doc, комментарии на русском, явные модификаторы, Allman, без регионов/магики.
- Дублирование «DELETE DedupEntries WHERE LeadId=?» в KanbanStore и `DeleteByLeadAsync` — осознанное
(Ruling 3): перенос метода в общий сервис создал бы зависимость Kanban → Pipeline; таблицы соседние в том
же TenantDbContext.
@@ -0,0 +1,48 @@
# Task 4 — «Чистое ядро разбора: cleaners/контакты/dedup-хэш/«О заявке»/local-fields» — отчёт
Статус: **DONE** (build 0/0, тесты 454/454 PASS, из них новых 44; ядро чистое — без EF/HTTP, цикла нет).
План: `docs/superpowers/plans/2026-09-05-deal-stage4-pipeline.md` Task 4 (L327349), Rulings 4/7;
источники — pipeline.py (L131341, L591798), ai.py normalize_dedup (L261267), правила/AmountParser Kanban.
## Файлы
### Созданы — ядро разбора (`Deal.Modules.Pipeline/Application/Parse/`, 1 тип = 1 файл, XML-doc, ссылки на строки прототипа)
| Файл | Назначение (1:1 с прототипом) |
|---|---|
| `MessageTextCleaner.cs` | clean_short/clean_block L148193 + регэкспы/эмодзи-диапазоны L131–145 + `CleanLine` (_clean_line L682683). Все шаги в порядке python: markdown-пары/ссылки → голые URL → защита «C#» → zero-width/nbsp → символы markdown → эмодзи (по кодовым точкам, суррогатные пары целиком) → маркеры списков → схлопывание → обрезка краёв → лимит по границе (L184–192) с «…». Внутренние rune-счётчики `CountCodePoints`/`SliceCodePoints` (python `len`/`[:n]` — кодовые точки, без разрыва пар). |
| `MessageListNormalizer.cs` | normalize_list L317329 (строка/список; запятая НЕ разделитель), normalize_stack L332341 (≤12, одиночные буквы — мимо), стоп-слова стека L604–610 (`StackStopWords`). |
| `ContactsQualifier.cs` | qualify_contact/build_contacts/primary_contact L344430 + _norm_phone L661663 + _contacts_from L666679. Выход — `CardContactDto` Kanban ({type, value}; tg/phone/email/linkedin/whatsapp/site). Ограничения/наборы: боты, t.me-сервисы, «постовые» сайты (teletype.in и т.п.), ≤6, дедуп по casefold. |
| `DedupHasher.cs` | normalize_dedup ai.py L261267: только `\w`-символы (буквы/цифры/`_`, по кодовым точкам) + lowercase → **SHA1** hex. Детерминирован, инвариантен к регистру/пунктуации/пробелам. |
| `SummaryComposer.cs` | compose_summary L225284 (блоки Компания→Формат→О задаче→Требования[≤14]→Будет плюсом[≤10]→Условия, 1:1 cardPrompt) + _local_summary L294314 + футер-хинты L288–291 (приватные — единственный потребитель). |
| `LocalFieldsParser.cs` | _local_fields L718798 + _field_of L686698 + метки L591–596 + токены/стоп-слова L597612 + fallback-извлечения (инлайн-«стек:», грейд по словам, бюджет из AmountParser). Маркеры hireMarkers/levelTerms — из настроек (дефолты SettingsDefaults, перекрытие сохранёнными; нормализация как IncomingRules) через `ParseAsync`; чистое ядро — статический `Parse(text, hireMarkers, levelTerms)`. Итог: is_vacancy маркерная гипотеза, known=false/board=null (полей нет — семантика в XML-doc, Ruling 5). |
| `AmountRangeBudgetFallback.cs` | fallback бюджета L459–468: первая сумма с валютой из (text, summary) через `AmountParser.Parse` Kanban → `BudgetRangeDto` (нормализацию делает вызывающий, Ruling 4). |
### Созданы — модели (`Deal.Modules.Pipeline/Application/Models/`)
- `LocalParsedFields.cs` — результат локального разбора: Title/Summary/Stack/Grade/Budget(`BudgetRangeDto?`)/Contacts(строка-кандидаты «; » ≤200)/IsVacancy.
- `ParsedLeadContent.cs` — структура блока «О заявке» (company/format/task/requirements/plus/conditions + legacy Summary) — вход `SummaryComposer.Compose` для ИИ-разбора (T6) и локального пути (T7).
### Изменён
- `PipelineModuleRegistrar.cs``AddScoped<LocalFieldsParser>()` (scoped, как IncomingRules: зависимость ISettingsStore; статические ядра не регистрируются).
## Тесты — `tests/Deal.Tests.Unit/MessageParseCoreTests.cs` (44 теста)
- **Cleaners**: markdown-пары/ссылки/голые URL/`#`, «C#»/«F#» не режутся, эмодзи-маркеры, буллеты строк, схлопывание переносов (CleanShort vs CleanBlock), обрезка по границе/жёсткая (L184–192).
- **normalize_list/stack**: «Java; Kotlin»/переносы, «Java, Kotlin» НЕ режется (запятая — не разделитель), очистка элементов, одиночные буквы, дедуп.
- **Контакты**: qualify по типам (@, t.me, email lower, телефон +7/8, linkedin, whatsapp, site-спам → null), build из текста/разбора (≤6, дедуп по регистру), primary (tg→phone→email).
- **Dedup-хэш**: детерминирован; «Тест!» ≡ «тест», регистр/пунктуация/пробелы не влияют; разные тексты ≠.
- **«О заявке»**: все 6 блоков в порядке Компания→…→Условия; пропуск пустых блоков; legacy-summary как есть; футер-хинт → локальный путь «О задаче: …».
- **LocalFieldsParser**: метки «Стек:/Грейд:/Контакты:/Бюджет:» (стек C# сохраняется, грейд middle, бюджет 15002000$ → USD, контакты), fallback без меток (инлайн-стек, бюджет/контакты из текста, is_vacancy по hire-маркерам), пустые маркеры → not vacancy, `ParseAsync` с переопределением hireMarkers и с дефолтами.
## Проверка
1. `dotnet build Deal.sln` (src/core) — 0 предупреждений / 0 ошибок (TreatWarningsAsErrors).
2. `dotnet test tests/Deal.Tests.Unit`**454/454 PASS** (было 410; новых 44 — MessageParseCoreTests).
3. Чистота модуля: в `Deal.Modules.Pipeline/**/*.cs` нет EF/Npgsql/Http/Infrastructure (grep — пусто); Kanban/Settings на Pipeline не ссылаются — цикла нет; Pipeline → Kanban только чистые помощники/модели (AmountParser, BudgetRangeDto, CardContactDto).
## Решения и отклонения (в рамках плана, 1:1 с прототипом)
- **Dedup — SHA1**, как зафиксировано Ruling 7/Task 23 и ai.py L266267 (`hashlib.sha1`); в постановке задачи упомянут «sha256» — не применял: это сломало бы 1:1 с прототипом и согласованный с Task 3 (DedupEntries.Hash) формат.
- «О заявке»-структура типизирована: `ParsedLeadContent` (вход compose) + `LocalParsedFields` (выход local-разбора). T6 (LocalAiClassifier) и T7 (CardComposer) маппят свои DTO → эти модели (raw-словарь python). known=false/board=null в record не вынесены (константы), задокументированы.
- Футер-хинты — приватная константа `SummaryComposer` (единственный потребитель; «файл MessageTextCleaner.cs» из плана трактован как общая зона чистки, дублирования нет); стоп-слова стека — `MessageListNormalizer.StackStopWords` (использует LocalFieldsParser._pick_stack, python L604610).
- Семантика python сохранена буквально, включая особенности: normalize_list срезает «#» у «C#» (strip('*`#'), L325) и запятая не разделитель; qualify t.me-коротких ссылок <4 символов («/s») даёт site (как python: группа {4,32} не матчится); голый URL съедает прилипшую запятую ([^\s…]+); кандидат «@ник» извлекается и из середины email (regex _CONTACT_RE). Это баги качества прототипа — НЕ «чинил» (Ruling 7: 1:1).
- Символы эмодзи удаляются по кодовым точкам (диапазоны python L137145; в .NET — ручной проход по Rune, а не regex по UTF-16).
- Ограничения-числа вынесены в именованные константы; обрезки строк — rune-safe (`SliceCodePoints`), чтобы не разрывать суррогатные пары.
@@ -0,0 +1,51 @@
# Task 5 — «PipelineService: приём (ingest), очередь, отсев, возврат, очистки, счётчики» — отчёт
Статус: **DONE** (build 0/0, тесты 483/483 PASS, из них новых 29; модуль чист — без EF/HTTP, цикла нет).
План: `docs/superpowers/plans/2026-09-05-deal-stage4-pipeline.md` Task 5 (L350368), Rulings 2/8/10;
источники — pipeline.py enqueue (L5385), processing.py (L66193, L201320), processing_routes.py (L1774),
api-map §3.6 (L178186) и §4.5 (L306313).
## Файлы
### Созданы — модуль Pipeline (`Deal.Modules.Pipeline/Application/`, 1 тип = 1 файл, XML-doc, ссылки на строки прототипа)
| Файл | Назначение (1:1 с прототипом) |
|---|---|
| `PipelineIngestService.cs` | Приём входящих — `EnqueueAsync(QueuedMessage, ct)` (Ruling 2, enqueue L5385): trim текста; пустой текст/нет dialogId → no-op; текст[:6000] кодовых точек (внутренний rune-safe `MessageTextCleaner.SliceCodePoints`); msg_id-дубль-гвард `ExistsDuplicateAsync(dialogId, msgId)` до вставки (Telethon-повтор); id `p_` (PrefixId), статус new, CreatedAt=UpdatedAt=now, msgAt=now при отсутствии; Force пробрасывается (возврат из отсева). Результат — `PipelineIngestResultDto{Id, Duplicate}` (прототип возвращает None; id/флаг нужны демо-ingest T9 и тестам). Дедуп по тексту — НЕ здесь (этап воркера T8, как в прототипе: enqueue дедуп не проверяет). |
| `PipelineProcessingService.cs` | Вся вкладка «Обработка» (processing.py): `RejectAsync` (record L66101: пустой текст no-op, text[:6000]/reason[:500]/kw[:200], hue-дефолт #666, детерминированный id `r_<dialog>_<msgId>` через `RejectRecord.DeterministicId`, upsert — не дубликат); `ListQueueAsync` (list_queue L218241, CreatedAt ASC, clamp 1..500); `QueueCountsAsync` (queue_counts L207215: new/ai=filtered/total); `RejectedCountAsync`; `ListRejectedAsync` (list_rejected L246312: no-q путь по RejectedAt DESC + счётчик; q-путь — FTS-кандидаты LIKE по lower(text/reason/kw/ch_name), total = размер объединения, страницы из кандидатов, q trim+lowercase; offset≥0, limit 1..500 — эхо в ответе); `ReturnAsync` (return_to_queue L128193, детали ниже); `DeleteAsync`/`ClearAsync`/`PurgeExpiredAsync` (3 суток через `PipelineRejectConstants.RetentionDays`, purge_expired L104117); `StatsAsync` (форма `/pipeline/stats`: {queue:{new,ai,total}, rejected}). 400-тексты возврата — public-константы (паттерн CardsService), их читают тесты и эндпоинты T9. |
| `PipelineQueueStatuses.cs` | Статусы очереди new/filtered (pipeline.py ST_NEW/ST_AI L4244) — первые потребители появились в T5 (приём пишет new, счётчики читают оба). |
| `Models/PipelineIngestResultDto.cs` | `{Id?, Duplicate}` — результат приёма (no-op/дубль отличимы). |
| `Models/PipelineStatsDto.cs` | `{Queue: QueueCountsDto, Rejected}` — тело GET /pipeline/stats (ключи queue/rejected). |
| `Models/RejectedPageDto.cs` | `{Items, Total, Offset, Limit}` — тело GET /pipeline/rejected (форма §3.6). |
| `Models/RejectReturnResultDto.cs` | `{Error?, Id, Returned, ReturnedAtMs(returnedAt)}` — тело POST …/return: 200 {id, returned:true, returnedAt} / 400 {detail}=Error; «записи нет» — null (404). |
### Изменён
- `PipelineModuleRegistrar.cs``AddScoped<PipelineIngestService>()` + `AddScoped<PipelineProcessingService>()` (scoped, как Kanban/Settings); XML-doc обновлён (состав модуля T4/T5, зависимость Processing → Ingest без цикла).
### Созданы — тесты (`tests/Deal.Tests.Unit/`)
| Файл | Содержание |
|---|---|
| `FakePipelineStore.cs` | In-memory `IPipelineStore` (все 24 метода, семантика EF-адаптера PipelineStore): очередь как есть + CreatedAt ASC/статус-фильтр/счётчики; отсев с upsert-по-id, при повторе аудит возврата переживает (ON CONFLICT прототипа); пустой текст no-op; детерминированный id; подписи через `PipelineRejectConstants`; LIKE-поиск 1:1 с SQL адаптера (lower(поле) LIKE %q% — сервис обязан lower-casить q, тест это ловит); seed-хелперы + коллекции для проверок. |
| `PipelineIngestServiceTests.cs` | 9 тестов: trim + строка p_/status new/времена/Force; msgAt отсутствует → now; no-op пустого/пробельного текста и без dialogId; дубль (dialogId+msgId) — второй не пишется (Duplicate); разные msgId и msgId=null — пишутся; text[:6000] по кодовым точкам. |
| `PipelineProcessingServiceTests.cs` | 20 тестов: Reject (пустой текст no-op; лимиты 6000/500/200 + hue #666 + id `r_d1_7`; повтор — upsert одной строки); счётчики (new/ai/total; stats queue+rejected); список очереди (порядок CreatedAt, clamp limit 0→1); страницы отсева (no-q DESC/offset/limit + эхо; q по text/reason/kw/ch_name; total = кандидаты; offset-страницы); возврат (не найдена → null; уже возвращено/dup/нет текста → 400-тексты констант; spam_ai → PushAsync(text,"spam",1.0) + запись returned/reason + force-строка в очередь с сохранением msgAt/канала; не-спам этап → без push; причина >500 режется; без dialog/msgId → прямая force-вставка с msgAt=now); delete/clear (счётчик)/purge-expired (только старше 3 суток). |
## Реализация возврата (1:1 с return_to_queue L128193)
- Порядок проверок как в прототипе: записи нет → null (404-текст на эндпоинте, паттерн CardsService→LeadsEndpoints); returned → 400 «Сообщение уже возвращено в обработку»; source=dup → 400 «Повтор: карточка с таким текстом уже есть в системе — возвращать нечего»; текст после trim пуст → 400 «В записи нет текста сообщения».
- Этап ∈ {spam_ml, spam_ai, filter_ai} → `IMlClient.PushAsync(text, "spam", 1.0)` (снятие веса спама, Ruling 5/10). Причина возврата trim + [:500] пишется через `MarkReturnedAsync` (запись НЕ удаляется).
- Две ветки очереди как в прототипе (L161192): dialog+msgId есть → полный путь `PipelineIngestService.EnqueueAsync` (дубль-гвард сохраняется, force=true, msgAt = row.msg_at or now); иначе (старые записи без dialog/msgId) → прямая вставка строки p_/new/force (L174192), hue-фолбэк #666 в обеих ветках.
## Проверка
1. `dotnet build Deal.sln` (src/core) — 0 предупреждений / 0 ошибок (TreatWarningsAsErrors, EnforceCodeStyleInBuild).
2. `dotnet test tests/Deal.Tests.Unit`**483/483 PASS** (было 454; новых 29).
3. Чистота модуля: в новых файлах `Deal.Modules.Pipeline/**/*.cs` нет EF/Npgsql/Http/Infrastructure (grep — пусто); Kanban/Settings на Pipeline не ссылаются — цикла нет; Processing → Ingest — внутри модуля, однонаправленно (Ingest о Processing не знает). Диагностики новых файлов — без ошибок/предупреждений.
## Решения и отклонения (в рамках плана, 1:1 с прототипом)
- **Тестовый файл плана разбит на два** (`PipelineIngestServiceTests` + `PipelineProcessingServiceTests`, 1 тема = 1 файл как в остальных задачах) — набор кейсов плана покрыт полностью (29 тестов суммарно).
- **EnqueueAsync возвращает `PipelineIngestResultDto`** (а не void/None): прототип ничего не возвращает, но демо-ingest (T9) отвечает {ok, id, queue}, а дубль-гвард нужно отличать от прочих no-op — две логичные добавки согласованы постановкой («Возвращает результат (id/дубликат/…)») и Ruling 11.
- **Статусы new/filtered вынесены в `PipelineQueueStatuses`**: первые потребители появились именно в T5 (приём/счётчики); T8-воркер переиспользует тот же класс. Тексты деталей возврата — public-константы сервиса (паттерн CardsService: тесты и эндпоинты T9 читают их, не дублируя строки).
- RejectAsync нормализует команду (лимиты/дефолт цвета) на слое сервиса, как и предписывает XML-doc `RejectRecord` («ограничения соблюдает слой сервиса»); пустой текст no-op остаётся и в адаптере (Task 3) — страховка порта, дубль не создаёт поведения.
- Автоочистка (3 суток) живёт в `PurgeExpiredAsync` (её вызовут тик/фоновый цикл в T10/T11) — фоновых циклов в модуле нет (Ruling 9). Отсев в боковой панели не показывается — счётчики/эндпоинты по плану сохранены.
@@ -0,0 +1,68 @@
# Task 6 — «Порт IAiClassifier + детерминированный LocalAiClassifier» — отчёт
Статус: **DONE** (build 0/0, тесты 489/489 PASS, из них новых 6 — LocalAiClassifierTests).
План: `docs/superpowers/plans/2026-09-05-deal-stage4-pipeline.md` Task 6 (L370388), Rulings 5/7/10;
источники — ai.py filter_incoming/classify (L188258), pipeline.py _local_fields (L718798) и compose_summary
(L225284), ТЗ §5 (L104106), эталон LocalColumnSuggester (ядро владельца + тонкий адаптер).
## Файлы
### Созданы — порт (`Deal.Contracts/Integrations/`, 1 тип = 1 файл, XML-doc, 1:1 с планом)
| Файл | Назначение |
|---|---|
| `IAiClassifier.cs` | Порт ИИ-классификатора (Ruling 5): `FilterAsync(string, ct)``AiFilterResultDto`, `ClassifyAsync(string, ct)``AiParsedLeadDto`. Этап 6 — замена реализации gRPC-клиентом ai-service, контракт стабилен. |
| `Models/AiFilterResultDto.cs` | `{Pass, Reason?, Skipped}` — 1:1 filter_incoming L188198 (pass/reason/skipped). |
| `Models/AiBudgetDto.cs` | `{From?, To?, Cur}` — нормализованный бюджет (clean_budget L316326; одна сумма → from=to, «до X» → from=null, from=0 → null). |
| `Models/AiContactDto.cs` | `{Type, Value}` — контрактная форма квалифицированного контакта (аналог Kanban CardContactDto; Contracts не может ссылаться на модуль — форма продублирована). |
| `Models/AiParsedLeadDto.cs` | Разбор лида: title + блок «О заявке» (company/format/task/requirements/plus/conditions) + legacy Summary + stack + budget + contacts + is_vacancy/is_vacancy_known/is_spam + board (структура ТЗ §5 и Ruling 5; Summary добавлен — legacy-суть compose_summary L264268, локальный путь T6/T7). |
### Создан — реализация (`Deal.Infrastructure/Integrations/LocalAiClassifier.cs`)
- Фильтр **всегда** `{pass:true, reason:null, skipped:true}` — реального ИИ-фильтра нет (Ruling 5); выключатель
aiFilterEnabled НЕ читается (ветки выключателя — у воркера T8, filter_incoming L190192 и L11031106).
- Классификатор: `LocalFieldsParser.ParseAsync` (ядро модуля Pipeline) → `AiParsedLeadDto`:
title/summary/stack 1:1; бюджет — `BudgetNormalizer.Normalize` (как clean_budget в _store_lead L453) →
`AiBudgetDto`; contacts — `ContactsQualifier.Build(fields.Contacts, text)` (build_contacts L389421, ≤6, дедуп,
боты/сервисные t.me/«постовые» сайты отброшены); is_vacancy — маркерная гипотеза hireMarkers;
**is_vacancy_known=false, board=null** («смысловые колонки до ИИ не назначаем», python L954958/L796797);
is_spam=false; блок «О заявке» пуст (поля null) — суть несёт Summary.
- Scoped: зависимость `LocalFieldsParser` (уже `AddScoped` в PipelineModuleRegistrar, T4); LocalMlClient-эталон не тронут.
### Изменён — `Deal.Infrastructure/ServiceCollectionExtensions.cs`
- `AddDealIntegrations`: `services.AddScoped<IAiClassifier, LocalAiClassifier>()` (Ruling 10: регистрация адаптеров
интеграций — здесь; воркер T8 получит порт через DI). XML-remarки метода дополнены.
## Тесты — `tests/Deal.Tests.Unit/LocalAiClassifierTests.cs` (6)
- Фильтр-пропуск: любой текст → `{pass:true, skipped:true, reason:null}` (ветка «ИИ недоступен»).
- Вакансия с метками «Стек:/Контакты:/Бюджет:» → title/summary/stack; бюджет 15002000$ → `{from:1500,to:2000,cur:USD}`;
контакты **квалифицированы** (@some_bot отброшен; остались `{tg,@dev_ivan}`, `{email,dev@q.ru}`); is_vacancy=true,
is_vacancy_known=false, board=null.
- Бюджет «до 2к$» → `{from:null, to:2000, cur:USD}` (суффикс «к» + валюта-суффикс).
- Заказ без бюджета/контактов/маркеров → is_vacancy=false, budget=null, contacts/stack пусты, блок «О заявке» пуст,
Summary непустая (legacy-путь).
- Детерминизм: одинаковый текст дважды → одинаковый DTO (поля/бюджет/контакты/стек по равенству).
- Маркеры найма из KV: переопределение hireMarkers перекрывает дефолты (как ParseAsync_T4).
## Проверка
1. `dotnet build Deal.sln` (src/core) — 0 предупреждений / 0 ошибок (TreatWarningsAsErrors).
2. `dotnet test Deal.sln`**489/489 PASS** (было 483; новых 6 — LocalAiClassifierTests).
3. Зависимости: Contracts остаётся листом (модули → Contracts); Infrastructure → Pipeline/Kanban/Settings —
только чистые ядра и модели владельцев (эталон LocalColumnSuggester); IAiClassifier наружу не торчит (эндпоинтов нет — воркер T8/T13).
## Решения и отклонения
- **AiContactDto — 4-й model-файл** (в «Files:» Task 6 перечислены 3 DTO): квалифицированный контакт — отдельный
тип (1 тип = 1 файл); контракт не может ссылаться на модульный CardContactDto. Отклонение минимально и в духе плана
(contacts «через ContactsQualifier», приёмка «контакты квалифицированы»).
- **Summary в AiParsedLeadDto** — сверх списка Ruling 5: без legacy-сути локальный разбор терял бы «О заявке»
(compose_summary L264268 возвращает raw["summary"] как есть); Task 6 сам требует маппинг «title/summary/…».
- **Contacts квалифицируются в классификаторе** (Build), а не оставляются сырыми: так задано Task 6; T7 (CardComposer)
получит уже квалифицированный список и смаппит 1:1 в CardContactDto (локальный путь aiEnabled=false идёт мимо порта —
там как в прототипе: сырая строка → Build в композере).
- is_spam в локальной реализации всегда false (отсевы spam_ai/filter_ai достижимы только этапом 6 — причины готовы, Ruling 5).
## Concerns для Task 7/8
- T7: `AiParsedLeadDto``ParsedLeadContent` (Company/Format/…/Summary) для `SummaryComposer.Compose`; budget —
нормализованный (Normalize уже сделан в T6, повторно не нормализовать); contacts — квалифицированные AiContactDto
→ CardContactDto.
- T8: FakeAiClassifier.cs — DTO строится с пустыми/заполненными полями (в т.ч. board → BoardAccepts-страховка,
is_spam → отсев spam_ai + Push(spam, 0.4)); «сбой/пустой разбор → локальный (aiFail)» реализуется в воркере
(порт исключений не бросает).
@@ -0,0 +1,77 @@
# Task 7 — «CardComposer / PipelineCardWriter (карточка через публичный интерфейс Kanban)» — отчёт
Статус: **DONE** (build 0/0, тесты 503/503 PASS, из них новых 14 — CardComposerTests 11 + PipelineCardWriterTests 3).
План: `docs/superpowers/plans/2026-09-05-deal-stage4-pipeline.md` Task 7 (L390410), Rulings 3/4/8/12;
источники — python pipeline.py `_store_lead` (L433514), rules.py board_accepts/hits (L251319), compose_summary
(L225284), build_contacts/primary_contact (L389430); эталоны — DemoLeadFactory (сборка CardSnapshot),
LocalAiClassifier (квалификация contacts в классификаторе, T6).
## Файлы
### Созданы — модуль Pipeline (`Deal.Modules.Pipeline/Application/`, 1 тип = 1 файл, XML-doc, 1:1 с планом)
| Файл | Назначение |
|---|---|
| `CardComposer.cs` | Сборка `CardSnapshot` из `AiParsedLeadDto` + строки-сообщения `QueueItemDto` (Ruling 4, `_store_lead` L433514): title (CleanShort 140, fallback text), «О заявке» (`SummaryComposer.Compose` блоки Компания→…→Условия → CleanBlock 2000, fallback CleanShort(text,2000)), stack (NormalizeStack ≤12), бюджет (BudgetNormalizer.Normalize из разбора; fallback первой суммы `AmountRangeBudgetFallback.Extract(text, summary)`), конверсия один раз при поступлении (`BudgetNormalizer.ToTarget`, чтение conversionOn/targetCurrency/ratesCache с мок-фолбэком), контакты (`ContactsQualifier.Build` по значениям разбора/тексту, ≤6, дедуп; contact = `ContactsQualifier.Primary` ≤200), ch/source-поля, sourceMsg = text[:4000], prevCol=inbox, isVacancy/isVacancyKnown. `BuildAsync` читает назначенную доску (`GetBoardAsync`) и применяет страховку `ColumnRules.BoardAccepts` (иначе col=inbox); matchHits = `ComputeHits` для прошедшей доски, иначе пусто (L449450/L473). |
| `PipelineCardWriter.cs` | Тонкая обёртка создания (L400–402): `PrefixId.New(KanbanIdPrefixes.Card)` (id `l_`, генератор владельца) → `CardComposer.BuildAsync``IKanjStore.AddCardAsync(snapshot)``IPipelineStore.LinkAsync(hash, cardId)` (порядок AddCard → Link, L512513) → `GetCardAsync(cardId)` — CardDto для SSE new_lead (Ruling 8/9). |
### Изменён — `Deal.Modules.Pipeline/Application/PipelineModuleRegistrar.cs`
- `AddPipelineModule`: `AddScoped<CardComposer>()` + `AddScoped<PipelineCardWriter>()` (зависимости — scoped порты ISettingsStore/IKanjStore/IPipelineStore, как IncomingRules; воркер T8 получит писателя через DI).
### Изменены — fakes тестов (`tests/Deal.Tests.Unit/`, только аддитивно, дефолты не менялись)
- `FakeKanjStore.cs`: `FailAddCard` — сбой записи карточки (сценарий ошибки AddCardAsync для тестов писателя).
- `FakePipelineStore.cs`: `DedupLeadId(hash)` — чтение LeadId строки дедупа (проверка «карточка связана»).
### Созданы — тесты (`tests/Deal.Tests.Unit/`)
- `CardComposerTests.cs` (11): полная сборка (блоки «О заявке», бюджет+conv по мок-курсам, контакты квалифицированы,
contact=primary, ch/source-поля, receivedAt=msgAt, isVacancy/Known, matchHits пуст); sourceMsg ≤4000 кодовых точек;
title-fallback из текста; доска без правил → колонка доски + пустые matchHits; доска с несовпадающими правилами →
inbox (BoardAccepts-страховка); доска с совпадающими правилами → колонка + hit {Слова, python}; доска отсутствует →
inbox; fallback-бюджет из текста (2000 USD from=to); conversionOn=false → conv-поля пусты; контакты-fallback из
текста; пустой hue → дефолт #666.
- `PipelineCardWriterTests.cs` (3): создание карточки через AddCardAsync + линк заявки дедупа (LeadId=cardId) и
перечитывание CardDto; назначенная доска (без правил) → карточка в колонке доски + линк; сбой AddCardAsync →
исключение наружу, карточки нет, заявка дедупа НЕ связана (LeadId=null остаётся).
## Проверка
1. `dotnet build Deal.sln` (src/core) — 0 предупреждений / 0 ошибок (TreatWarningsAsErrors, -v q).
2. `dotnet test Deal.sln`**503/503 PASS** (было 489; новых 14 — CardComposer/PipelineCardWriter).
3. Зависимости: Pipeline пишет карточку ТОЛЬКО через публичный порт владельца `IKanjStore.AddCardAsync` + чистые
помощники Kanban (ColumnRules/BudgetNormalizer/PrefixId) — реверс-зависимостей нет (Kanban о Pipeline не знает,
Ruling 3); добавление ссылки Pipeline→Kanban цикла не создаёт.
## Решения и отклонения
- **Имя метода порта `LinkAsync`** (в плане текстом «LinkDedupAsync»): фактическое имя — `IPipelineStore.LinkAsync`
(реализовано T2/T3, XML-doc «связывает заявку дедупа с созданной карточкой», Ruling 4). Используем `LinkAsync`.
- **`PipelineCardWriter` принимает `AiParsedLeadDto` + `QueueItemDto` + hash** и сам держит `CardComposer`
(композиция внутри, а не вызов из воркера): план T8 перечисляет в зависимостях воркера и CardComposer, и
IKanjStore/IPipelineStore — фактическая точка входа одна (`writer.CreateCardAsync`), что для T8 проще; это
уточнение границ в духе плана («тонкая обёртка создания: PrefixId → AddCard → Link → GetCard»).
- **Гвард «уже есть карточка по дедупу» в писателе НЕ дублируется**: по Ruling 8 повтор-проверка выполняется на
«new»-проходе pump (L940–951), к моменту записи заявка дедупа уже создана (LeadId=null) и её наличие НЕ должно
блокировать создание (иначе карточка не создалась бы никогда). Тест «дубликат» на уровне писателя = сбой записи
не линкует заявку. Соответствие: python `_store_lead` тоже не пере-проверяет dedup.
- **Транзакционности «карточка + dedup-link» нет** (как и в плане/прототипе): KanbanStore/PipelineStore — отдельные
адаптеры на общем scoped TenantDbContext, каждый метод — одиночный statement/SaveChanges (Ruling 1/3); порядок
AddCard → Link 1:1 с L512513, сбой LinkAsync оставляет карточку без связи и пробрасывается (обработает воркер T8).
- **Контакты разбора пере-квалифицируются `ContactsQualifier.Build`** по значениям AiContactDto + текст (Ruling 4
«Build из разбора или текста»): для списка из классификатора (T6) повторная квалификация идемпотентна (значения
уже квалифицированы), а при пустом списке срабатывает python-fallback кандидатов из текста (L406–407). Результат —
≤6, дедуп по значению.
- **Бюджет разбора нормализуется `BudgetNormalizer.Normalize` повторно** (как `clean_budget` в `_store_lead` L453;
для уже нормализованного T6-бюджета — идентичность); fallback-сумма из AmountParser тоже нормализуется — по плану
«бюджет Normalize + fallback AmountParser по text/summary (первая сумма)».
- **Дефолт цвета канала `#666`** в композиторе при пустом hue: строка QueueItems может нести пустой hue (адаптеры
пишут DTO-значение как есть), а у карточки пустой цвет бессмыслен; семантика «дефолт #666» — Ruling 1 и как у
отсева (PipelineProcessingService).
- **Курсы для BoardAccepts/ComputeHits передаются в `ColumnRules`** (загружены для конверсии): бюджетные правила
колонки сравниваются с конвертацией валюты, как rules.py через rates (CardsService.LoadRatesAsync-эталон).
## Concerns для Task 8
- Локальный путь (aiEnabled=false) «мимо порта»: воркер должен привести LocalFieldsParser-результат к
`AiParsedLeadDto` (бюджет/контакты нормализованы так же, как в LocalAiClassifier) либо использовать
`LocalAiClassifier.ClassifyAsync` — иначе композитор получит сырые поля. T6-report уже отмечал это разграничение.
- no-budget фильтр воркера должен считать «сумма есть» тем же способом, что композитор: parsed.Budget либо первая
сумма из text/summary (`AmountRangeBudgetFallback`) — иначе рассогласование «отсев без суммы» vs «карточка с
fallback-бюджетом».
- Воркер вызывает `writer.CreateCardAsync(parsed, queueItem, hash)` ПОСЛЕ своих проверок (dup на «new»-проходе уже
сделан, заявка ClaimAsync есть); результат — CardDto для `CreatedCards`/SSE и счётчиков mlStored/aiStored.
@@ -0,0 +1,96 @@
# Task 8 — «PipelineWorkerService (pump 1:1: очередь → правила → дедуп → ML → ИИ → карточка/отсев)» — отчёт
Статус: **DONE** (build 0/0, тесты 521/521 PASS, из них новых 18 — PipelineWorkerServiceTests 18; регрессия LocalAiClassifierTests зелёная после выноса маппинга).
+ ревью-фикс «стемп is_vacancy_known на ИИ-пути» (см. «Fix по ревью» в конце).
План: `docs/superpowers/plans/2026-09-05-deal-stage4-pipeline.md` Task 8 (L412435), Rulings 2/4/5/8/9;
источники — python `_pump_unlocked` (pipeline.py L9201183), `_skip_no_budget` (L196218), `_local_fields`
(L718798), ml_client.py (AI_WEIGHT/track_decisions/is_enabled L2627/L153161), ai.py filter_incoming (L188198).
## Файлы
### Создан — модуль Pipeline (`Deal.Modules.Pipeline/Application/`, 1 тип = 1 файл, XML-doc, порядок строго 1:1)
| Файл | Назначение |
|---|---|
| `PipelineWorkerService.cs` | Чистый оркестратор pump — `PumpOnceAsync` = `_pump_unlocked` L9201183. «new»-батч (12): force? → stale (только не force, msgAt старше archiveAfterDays суток при autoArchive → отсев `{stale/stale, «сообщение старше N дн. …»}`) → `IncomingRules.CheckAsync` (не прошёл → отсев `{stop/kind, reason, kw}`, строка+claim удаляются) → дедуп `DedupHasher` (хэш в системе → отсев `{dup/dup}`; иначе `ClaimAsync`) → ML-слот (mlEnabled не false и не force; `PredictSafelyAsync` — сбой/неготов/неуверен → filtered): spam → `{ml/spam_ml, «ML уверен… (score X.XX)»}`; доска (не-suggested, без активных правил) → локальные поля + board=метка + тип ML (если take) + доклад terms (≤4, `MergeMlTerms`) → карточка в доску, mlStored; тип ML → typeDrop по wantedType (`{ml/type}`) либо при aiEnabled=false карточка inbox (is_vacancy/known от ML), mlStored → не решено → status=filtered. «filtered»-батч (4): stale → aiEnabled=false → локальный путь (`AiLeadMapper`), no-budget(не force) → отсев `{stop/budget}` + claim снят; иначе force → фильтр-пропуск, aiFilterEnabled=false → пропуск, `FilterSafelyAsync` (сбой → пропуск) → `ClassifyAsync` (сбой → локальный разбор, aiFail++) → вердикт «спам» (force отменяет только для force, L11171121) → отсев `{ai/spam_ai|filter_ai}` + `PushAsync(text,"spam",0.4)` → no-budget (не force) → карточка (`PipelineCardWriter`; col по BoardAccepts, иначе inbox) + обучение ML колонка (свободная доска) / тип (`t:hire`/`t:order`, is_vacancy_known), оба 0.4. Итог: `PipelinePumpResult` (+`CreatedCards`); KV `mlDecisions=mlStored+mlDrop`, `aiDecisions=aiStored+aiDrop` (read-modify-write через ISettingsStore, Ruling 5). Результат возвращается — new_lead публикует Api-слой (T10/T11). |
| `AiLeadMapper.cs` | Статический модульный маппинг `LocalParsedFields → AiParsedLeadDto` (бюджет `BudgetNormalizer.Normalize`, контакты `ContactsQualifier.Build`, board=null, is_vacancy_known=false) — единый источник истины для локальных путей воркера (aiEnabled=false / aiFail / ML-ветка) и адаптера LocalAiClassifier (концерн T7-report «мимо порта» закрыт: маппинг один). |
### Изменён — модуль Pipeline
- `PipelineModuleRegistrar.cs`: `AddScoped<PipelineWorkerService>()` (вызывают T10 tick / T11 цикл из Api — модуль циклы не заводит).
- (Инфраструктура) `LocalAiClassifier.cs`: `ClassifyAsync` делегирует `AiLeadMapper.FromLocal` — удалены приватные NormalizeBudget/BuildContacts (поведение то же, LocalAiClassifierTests 11 PASS зелёные).
### Изменены/созданы — тесты (`tests/Deal.Tests.Unit/`)
- `FakeMlClient.cs` (аддитивно): `Predict` (ответ PredictAsync; null → NotSupportedException, как было), `PredictCalls` — вызовы predict (force/выключен → 0).
- `FakeAiClassifier.cs` (создан): `FilterResult`/`ClassifyResult`/`FilterThrows`/`ClassifyThrows` + `FilterCalls`/`ClassifyCalls`; дефолт фильтра — как LocalAiClassifier (pass+skipped).
- `PipelineWorkerServiceTests.cs` (17): (1) короткое → length, строка удалена; (2) стоп-фраза → stop с kw «взаимный пиар»; (3) резюме → resume (kw «готов к собеседованию», stopPhrases перекрыты пустыми); (4) dup: дважды один текст — второе dup, первое → карточка (1 прогон, оба прохода); (5) stale (archiveAfterDays=1, msgAt 2 сут) → отсев БЕЗ карточки; (6) вакансия → карточка inbox, aiStored=1, KV aiDecisions=1, CreatedCards=1, predict вызван (ML «спит»); (7) no-budget при budgetRequiredHire → отсев budget, claim удалён, aiDecisions не тронут; (8) ML ready+spam → spam_ml (score 0.90) + mlDecisions, ИИ не зван; (9) ML ready+доска → карточка в b_py с термином «python», БЕЗ обучающего push; (10) ML ready+тип+aiEnabled=false → карточка inbox is_vacancy/known=true, mlStored; (11) force → минует правила/stale/no-budget/ML (PredictCalls=0), карточка создана; (12) ИИ-слот: доска под несовпадающие правила → inbox (BoardAccepts); is_spam → spam_ai + Push(spam, 0.4); сбой классификатора → локальная карточка + aiFail; фильтр заблокировал → filter_ai (классификатор не зван); (13) сбой записи карточки → исключение наружу, строка (filtered) и claim остаются; (14) force отменяет вердикт «спам» ИИ → карточка без обучения.
## Проверка
1. `dotnet build Deal.sln` (src/core) — 0 предупреждений / 0 ошибок (TreatWarningsAsErrors, -v q).
2. `dotnet test Deal.sln`**520/520 PASS** (было 503; новых 17 — PipelineWorkerServiceTests; регрессии нет).
3. Модуль чистый: pump 1:1 с `_pump_unlocked`; публикации нет (CreatedCards наружу), циклов нет (воркер вызывается Api, T10/T11); Kanban/Settings о Pipeline не знают; IAiClassifier/IMlClient — порты Contracts.
## Решения и отклонения
- **«rulesStored» всегда 0, как в прототипе**: python `_pump_unlocked` нигде не инкрементирует `res["rulesStored"]`
(словарь L921, ветки L928–951 — без счётчика); поле результата остаётся wire-совместимым нулём (счётчики тика 1:1 с
прототипом). Отсевы правил/повторов/stale проверяются в тестах по записям RejectedItems, а не по этому счётчику.
- **ML-слот «не готов/не уверен → filtered» гейтится `decision.Ready && decision.Take`** (план L420–421: «не готов/не
уверен → filtered»); готовые решения fake-клиентов — Ready=true. На этапе 4 LocalMlClient не готов (predict
take:false) — все сообщения уходят к ИИ-ветке, но ML-ветки реализованы ПОЛНОСТЬЮ 1:1 с L969–1061 и покрыты тестами.
- **`PredictSafelyAsync`/`FilterSafelyAsync`/классификация с try/catch** — 1:1 с python (ml_client.predict L101107 —
сбой → «не уверен»; L1102–1106 — сбой фильтра → пропуск; L1112–1114 — сбой classify → локальный разбор + aiFail).
Сбой хранилища/писателя (например, AddCardAsync) НЕ ловится — исключение пробрасывается вызывающему (как python:
pump падает, фоновый цикл логирует; упавшая строка остаётся в очереди с claim — тест 13).
- **ИИ «разбор пуст»** в .NET неотличим от «нет разбора» (порт возвращает не-null record; LocalAiClassifier всегда
даёт разбор) — aiFail наступает по исключению классификатора (fake `ClassifyThrows`), как python при недоступном ИИ.
- **Маппинг локального разбора вынесен в `AiLeadMapper`** (модуль) и переиспользован LocalAiClassifier — закрыт концерн
T7-report («мимо порта»): aiEnabled=false/aiFail/ML-пути воркера строят ровно тот AiParsedLeadDto, что дал бы
классификатор (бюджет/контакты нормализованы одинаково); дублирования маппинга между модулем и Infrastructure нет.
- **no-budget «сумма есть»** = `parsed.Budget != null` ИЛИ `AmountParser.Parse(text).Count > 0` (python L216218:
clean_budget(raw.budget) + extract_amounts(text)) — фильтр и композитор смотрят в один источник сумм.
- **force** = `[JsonIgnore] QueueItemDto.Force` (T2): минует правила/stale/ML (L963965) и ИИ-фильтр (L10971100),
no-budget (L1143); вердикт «спам» ИИ отменяется (L1117–1121) — покрыто тестами 11 и 14. Дедуп для force НЕ
пропускается (1:1 L940951).
- **Возврат результата вместо публикации**: pump возвращает PipelinePumpResult+CreatedCards; SSE new_lead публикует
Api (Ruling 8/9: «из Api после PumpOnce — admin/tick и PipelineWorkerScheduler») — T10/T11.
## Concerns для Task 9/10/11
- T10 (admin/tick): tick вызовет PumpOnceAsync и разложит pipeline-словарь из PipelinePumpResult (wire-ключи —
camelCase-имена свойств 1:1); CreatedCards → SSE new_lead по одной карточке.
- T10: publish-цикл и «строки, переведённые в filtered в «new»-батче, видит «filtered»-батч того же прогона» — 1:1 с
прототипом (тест 4/6 это поведение фиксирует).
- T11: фоновый цикл должен ловить исключения PumpOnceAsync (иначе упадёт hosted service), как `_pipeline_loop`
(main.py L8087); после сбоя строка с claim останется и будет обработана/отсеяна следующим тиком (прототип-квирк).
- Регистрация воркера уже в `AddPipelineModule`; внешние порты (IMlClient/IAiClassifier/IPipelineStore/IKanjStore/
ISettingsStore) регистрируются AddDealPersistence/AddDealIntegrations (T3/T6) — в DI-графе Program.cs появится после
T9.
## Fix по ревью — стемп `is_vacancy_known` после успешной ИИ-классификации
**Замечание (Important):** python `_pump_unlocked` L11081111 ставит `raw["is_vacancy_known"] = True` ПОСЛЕ любого
успешного classify на ИИ-пути (raw непуст) и ДО создания карточки (L1148). В первой версии стемп стоял только в
ML-ветках, из-за чего на ИИ-пути тип никогда не был «подтверждён» (LocalAiClassifier возвращает known=false) —
обучающие push `t:hire`/`t:order` (Ruling 8, L11751180) в проде были недостижимы, позитивных тестов обучения у
`LearnFromAiCardAsync` не было.
**Изменения (файлы):**
- `PipelineWorkerService.cs` — в `PumpFilteredPassAsync` после успешного `ClassifyAsync` (parsed не null) разбор
получает стемп `parsed with { IsVacancyKnown = true }` (комментарий со ссылкой на L1108–1111). Стемп ставится ДО
проверок спама/no-budget/создания карточки → карточка ИИ-пути получает known=true, а force-отмена вердикта «спам»
его переживает (L1117–1121 сохраняет known) — 1:1 с python. Локальные пути без порта (aiEnabled=false, aiFail)
стемпа НЕ имеют (python: raw={} → _local_fields, стемпа нет). Классовый XML-doc обновлён.
- `PipelineWorkerServiceTests.cs` — обновлены сценарии под 1:1-стемп: (6) карточка inbox теперь known=true и учит
`t:hire` 0.4 (было «не учим»); (12a) inbox-fallback после успешного classify — тип подтверждён, учится `t:hire`
(колонку не учим); (14) force-отмена «спама» — карточка known=true, учится `t:hire`, push «spam» отсутствует
(было «без обучения»). Добавлен позитивный тест (15): ИИ-карточка в свободную доску при fake-классификаторе с
known=false → стемп воркера → карточка в колонке с is_vacancy_known=true и оба обучающих сигнала
`(text, boardId, 0.4)` + `(text, "t:hire", 0.4)` (порядок L1172 → L11761180).
**Проверка (src/core):**
1. `dotnet test tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj --filter "FullyQualifiedName~PipelineWorkerServiceTests"` — PASS.
2. `dotnet build Deal.sln` (в составе `dotnet test`) — 0 предупреждений / 0 ошибок.
3. `dotnet test Deal.sln`**521/521 PASS** (было 520; новых 1 — тест 15; регрессии нет).
**Concerns:** стемп применяется к любому успешному разбору ИИ-пути, включая детерминированный LocalAiClassifier
(маркерная гипотеза типа становится «подтверждённой» слоем ИИ и учит ML с весом 0.4) — это точное поведение python
L1108–1111 для реального ИИ; с появлением настоящего ИИ (этап 6) семантика не меняется. Порт `LocalAiClassifier`
(ClassifyAsync → known=false) намеренно не тронут — стемп — ответственность воркера (1:1 с прототипом), а не
классификатора.
@@ -0,0 +1,236 @@
#!/usr/bin/env sh
# Task 9 curl-приёмка: эндпоинты /api/pipeline/* + POST /api/demo/ingest на :5080 (план Task 9 L458460;
# Ruling 10/11). Сценарий: чистка pipeline-таблиц (QueueItems/RejectedItems/DedupEntries) → запуск Deal.Api
# с DEAL_DEMO=1 (Development) → 401 без куки (/pipeline/* и /demo/ingest) → login admin/admin → stats нули →
# demo/ingest вакансии (dialog+msgId) → очередь 1 → повтор того же dialog+msgId → очередь НЕ растёт (гвард) →
# demo/ingest текста со стоп-фразой (dialog2) → очередь 2 → GET /queue?limit=120: items/counts/rejected →
# GET /rejected (пустая страница {items,total,offset,limit}) → return несуществующей → 404 «Запись не найдена»
# → DELETE несуществующей → {ok:true} (404 не шлём) → POST /rejected/clear → {ok, cleared:0} → logout → 401.
# Приёмка отсева/return/clear на реальных записях — Task 13 (нужен pump: admin/tick Task 10 / цикл Task 11).
# В конце — остановка приложения и очистка pipeline-строк (таблицы/схема остаются).
set -u
BASE_URL="http://localhost:5080"
API_DIR="C:/telbase/src/core/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
WORK="/tmp/task9"
JAR="$WORK/jar.txt"
OUT="$WORK/out.txt"
LOG="$WORK/api.log"
BODY_DIR="$WORK/bodies"
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
SCHEMA="tenant_00000000000000000000000000000001"
PASS_COUNT=0
FAIL_COUNT=0
APP_PID=""
check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1
shift
ok=1
for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then
ok=0
fi
done
if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*"
echo "--- ответ:"
cat "$OUT"
fi
}
check_absent() {
# $1 — описание; $2 — подстрока, которой НЕ должно быть в $OUT
desc=$1
pat=$2
if grep -qF -- "$pat" "$OUT"; then
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — найдено нежелательное: $pat"
echo "--- ответ:"
cat "$OUT"
else
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
fi
}
stop_app() {
if [ -n "${1:-}" ] && kill -0 "$1" 2>/dev/null; then
kill "$1" 2>/dev/null
sleep 2
if netstat -ano 2>/dev/null | grep ':5080' | grep -qi listening; then
taskkill //F //PID "$1" 2>/dev/null
sleep 1
fi
fi
echo " [INFO] Deal.Api остановлен"
}
psql_clear_pipeline() {
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"QueueItems\";" >/dev/null 2>&1
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"RejectedItems\";" >/dev/null 2>&1
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"DedupEntries\";" >/dev/null 2>&1
}
cleanup() {
echo
echo "== Завершение (trap): остановка процесса и очистка pipeline-строк =="
stop_app "$APP_PID"
psql_clear_pipeline
rm -rf "$WORK"
}
trap cleanup EXIT INT TERM
rm -rf "$WORK"
mkdir -p "$BODY_DIR"
echo "== 0. Очистка pipeline-таблиц дефолтного тенанта (повторяемость приёмки) =="
PID_5080=$(netstat -ano 2>/dev/null | grep ':5080' | grep -i listening | awk '{print $NF}' | head -1)
if [ -n "$PID_5080" ]; then
echo " [WARN] порт 5080 занят pid $PID_5080 — останавливаю"
taskkill //F //PID "$PID_5080" >/dev/null 2>&1
sleep 1
fi
psql_clear_pipeline
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT (SELECT count(*) FROM \"$SCHEMA\".\"QueueItems\") + (SELECT count(*) FROM \"$SCHEMA\".\"RejectedItems\") + (SELECT count(*) FROM \"$SCHEMA\".\"DedupEntries\");")
if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] QueueItems/RejectedItems/DedupEntries пусты"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
exit 1
fi
echo
echo "== 0a. Запуск Deal.Api на :5080 с DEAL_DEMO=1 (Development) =="
cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development DEAL_DEMO=1 "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$!
i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1))
if [ "$i" -ge 45 ]; then
echo " [FAIL] сервер не поднялся за 45 с (лог: $LOG)"
tail -n 30 "$LOG"
exit 1
fi
sleep 1
done
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
sleep 2
echo
echo "== 1. 401 без сессии: /api/pipeline/* и /api/demo/ingest =="
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/pipeline/stats" > "$OUT"
check "GET /pipeline/stats без куки → 401" '[HTTP:401]' 'Требуется авторизация'
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/pipeline/queue" > "$OUT"
check "GET /pipeline/queue без куки → 401" '[HTTP:401]' 'Требуется авторизация'
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/pipeline/rejected" > "$OUT"
check "GET /pipeline/rejected без куки → 401" '[HTTP:401]' 'Требуется авторизация'
curl -s -w "\n[HTTP:%{http_code}]" -X POST "$BASE_URL/api/demo/ingest" -H "Content-Type: application/json" -d '{"text":"x"}' > "$OUT"
check "POST /api/demo/ingest без куки → 401" '[HTTP:401]' 'Требуется авторизация'
echo
echo "== 2. Login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
check "login 200 ok" '[HTTP:200]' '"ok":true'
echo
echo "== 3. GET /pipeline/stats — пустая форма {queue:{new,ai,total}, rejected:0} =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/pipeline/stats" > "$OUT"
check "stats 200, форма queue/rejected" '[HTTP:200]' '"queue":{' '"new":0' '"ai":0' '"total":0' '"rejected":0'
echo
echo "== 4. demo/ingest вакансии (dialogId demo_channel, msgId 1001) → очередь 1 =="
cat > "$BODY_DIR/ingest_vacancy.json" <<'EOF'
{"text":"Middle Python разработчик. Бюджет 1600-2200$. Стек: Python, FastAPI. Контакт @crm_head, tg: @crm_head. Задачи: разработка API.","dialogId":"demo_channel","channelName":"Демо-канал","channelHandle":"demo_channel","channelHue":"#0a7","msgId":1001}
EOF
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/demo/ingest" \
-H "Content-Type: application/json" --data @"$BODY_DIR/ingest_vacancy.json" > "$OUT"
check "ingest 200 {ok, id p_, queue new=1}" '[HTTP:200]' '"ok":true' '"id":"p_' '"new":1' '"total":1'
echo
echo "== 5. Повтор demo/ingest того же dialogId+msgId → гвард: id null, очередь не растёт =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/demo/ingest" \
-H "Content-Type: application/json" --data @"$BODY_DIR/ingest_vacancy.json" > "$OUT"
check "повтор 200 {ok, id:null}" '[HTTP:200]' '"ok":true' '"id":null'
check_absent "в очереди по-прежнему total=1 (не 2)" '"total":2'
echo
echo "== 6. demo/ingest текста со стоп-фразой (dialogId demo_channel2, msgId 2002) → очередь 2 =="
cat > "$BODY_DIR/ingest_stop.json" <<'EOF'
{"text":"Предлагаю взаимный пиар: разместим посты друг друга бесплатно, подпишемся взаимно.","dialogId":"demo_channel2","channelName":"Канал-2","msgId":2002}
EOF
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/demo/ingest" \
-H "Content-Type: application/json" --data @"$BODY_DIR/ingest_stop.json" > "$OUT"
check "ingest 200 {ok, id p_, queue new=2}" '[HTTP:200]' '"ok":true' '"id":"p_' '"new":2' '"total":2'
echo
echo "== 7. GET /pipeline/queue?limit=120 — items/counts/rejected =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/pipeline/queue?limit=120" > "$OUT"
check "queue 200: items из 2 строк" '[HTTP:200]' '"items":[' '"total":2' '"rejected":0'
check "queue: у строки форма §4.5 (id/status/ch/text)" '"status":"new"' '"dialogId":"demo_channel"' '"text":"Middle Python' '"msgAt"'
echo
echo "== 8. GET /pipeline/rejected — пустая страница {items,total,offset,limit} (pump в T10/T11) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/pipeline/rejected" > "$OUT"
check "rejected 200: пустой список + пагинация" '[HTTP:200]' '"items":[]' '"total":0' '"offset":0' '"limit":100'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/pipeline/rejected?q=%D0%BF%D0%B8%D0%B0%D1%80" > "$OUT"
check "rejected 200 c q (FTS/LIKE-путь, записей нет)" '[HTTP:200]' '"items":[]' '"total":0'
echo
echo "== 9. return/delete/clear на уровне обработки (записей отсева нет — формы ошибок/ok) =="
echo
cat > "$BODY_DIR/return.json" <<'EOF'
{"reason":"тест"}
EOF
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/pipeline/rejected/r_missing/return" \
-H "Content-Type: application/json" --data @"$BODY_DIR/return.json" > "$OUT"
check "return несуществующей → 404 «Запись не найдена»" '[HTTP:404]' 'Запись не найдена'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X DELETE "$BASE_URL/api/pipeline/rejected/r_missing" > "$OUT"
check "DELETE несуществующей → {ok:true} (404 не шлём)" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/pipeline/rejected/clear" > "$OUT"
check "clear пустого отсева → {ok, cleared:0}" '[HTTP:200]' '"ok":true' '"cleared":0'
echo
echo "== 10. stats после ingest — очередь 2, отсев 0; очередь в БД =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/pipeline/stats" > "$OUT"
check "stats: queue new=2, rejected=0" '"new":2' '"total":2' '"rejected":0'
DB_Q=$($PSQL_BASE -t -A -c "SELECT count(*) FROM \"$SCHEMA\".\"QueueItems\";")
if [ "$DB_Q" = "2" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] psql: QueueItems = 2"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] psql: QueueItems = $DB_Q (ожидалось 2)"
fi
echo
echo "== 11. Logout → 401 без куки =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
check "logout 200 ok" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/pipeline/stats" > "$OUT"
check "GET /pipeline/stats после logout → 401" '[HTTP:401]' 'Требуется авторизация'
echo
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
stop_app "$APP_PID"
APP_PID=""
psql_clear_pipeline
if [ "$FAIL_COUNT" -gt 0 ]; then
exit 1
fi
exit 0
@@ -0,0 +1,71 @@
# Task 9 — «Эндпоинты /api/pipeline/* + /api/demo/ingest + DI + curl-приёмка» — отчёт
Статус: **DONE**. Сборка 0 warnings / 0 errors; тесты 521/521 PASS (регрессии нет, новых unit-тестов не
требовалось — endpoint-слои покрываются curl, план Task 9 L453454); curl-приёмка на :5080 — 22/22 PASS.
План: `docs/superpowers/plans/2026-09-05-deal-stage4-pipeline.md` Task 9 (L437460), Rulings 2/6/10/11;
контракт — api-map §3.6 L178186, §4.5 L308315; прототип — processing_routes.py L1774 (processing.py
L128320 — сервис T5).
## Файлы
### Создан — `Deal.Api/Endpoints/`
| Файл | Назначение |
|---|---|
| `PipelineEndpoints.cs` | `MapPipelineEndpoints` — группа `/api/pipeline` (тег processing, 1:1 с processing_routes.py): GET `/stats``PipelineStatsDto` `{queue:{new,ai,total}, rejected}`; GET `/queue?limit=``{items, counts:{new,ai,total}, rejected}` (дефолт 100, clamp 1..500 в сервисе; фронт шлёт 120); GET `/rejected?q=&offset=&limit=``RejectedPageDto` `{items,total,offset,limit}` (q — FTS LIKE-путь Ruling 6, offset ≥ 0/limit 1..500 — clamp сервиса, эхо в ответе); POST `/rejected/clear``{ok, cleared}`; DELETE `/rejected/{rejId}``{ok:true}` всегда (delete_one L196198, 404 не шлём — Ruling 10); POST `/rejected/{rejId}/return` `{reason=""}``{id, returned:true, returnedAt}` / 400 (строки-константы `PipelineProcessingService` 1:1) / 404 «Запись не найдена» (текст 404 — слой эндпоинтов). Все — 401-гейт `HasUser` + резолв сервиса из RequestServices ПОСЛЕ проверки сессии (эталон MlEndpoints/StorageEndpoints); статические сегменты до `{rejId}`. |
| `RequestModels/ReturnReasonRequest.cs` | Тело return — `{reason?}` (pydantic reason: str = ""; wire camelCase). |
| `RequestModels/PipelineIngestRequest.cs` | Тело demo/ingest — `{text, dialogId?, channelName?, channelHandle?, channelHue?, msgId?, msgAt?}` (Ruling 10, wire camelCase). |
### Изменён — `Deal.Api/Endpoints/DemoEndpoints.cs`
- `POST /api/demo/ingest` (IngestPath `/ingest`, группа `/api/demo`, тег dashboard): 401 без куки → флаг
`DemoOptions.Enabled` (DEAL_DEMO) иначе 404 «Демо-режим отключён» → пустой/пробельный text → 400 «Текст
сообщения пуст» (Ruling 10) → `PipelineIngestService.EnqueueAsync` (Ruling 2: trim, ≤6000, гвард
dialogId+msgId) → ответ `{ok:true, id, queue:{new,ai,total}}` (счётчики — `PipelineProcessingService.QueueCountsAsync`
после приёма). Дубль dialogId+msgId и no-op (нет dialogId) — `id: null`, очередь не растёт. Публикаций SSE
нет (new_lead публикует Api после pump — Rulings 8/9; T10/T11).
### Изменён — `Deal.Api/`
- `Program.cs`: `builder.Services.AddPipelineModule()` (после AddKanbanModule) + `app.MapPipelineEndpoints()`
(Ruling 10: DI-граф модуля появляется в Api; адаптеры IPipelineStore/IMlClient/IAiClassifier уже в
AddDealPersistence/AddDealIntegrations — T3/T6).
- `Deal.Api.csproj`: ProjectReference на `Deal.Modules.Pipeline`.
## Проверка
1. `dotnet build Deal.sln` (src/core) — 0 предупреждений / 0 ошибок (TreatWarningsAsErrors).
2. `dotnet test Deal.sln` (tests/Deal.Tests.Unit) — **521/521 PASS**.
3. curl-приёмка (`.superpowers/sdd/deal-stage4-pipeline/task-9-curl-acceptance.sh`, DEAL_DEMO=1, admin/admin,
:5080; лог — task-9-curl-acceptance.log) — **22/22 PASS**: 401 без куки (stats/queue/rejected/demo-ingest) →
login → stats `{queue:{new:0,ai:0,total:0}, rejected:0}` → demo/ingest вакансии (dialog demo_channel, msgId
1001) → `{ok, id:p_…, queue new:1}` → повтор того же dialogId+msgId → `id:null`, очередь НЕ растёт (гвард) →
demo/ingest текста со стоп-фразой (dialog demo_channel2, msgId 2002) → очередь 2 → GET
`/pipeline/queue?limit=120` — items §4.5 (id/status/text/ch/msgAt), counts, rejected → GET `/pipeline/rejected`
и `?q=пиар` — пустая страница `{items,total,offset,limit}` → return несуществующей → 404 «Запись не найдена»
→ DELETE несуществующей → `{ok:true}` (404 не шлём) → `/rejected/clear``{ok:true, cleared:0}` → stats
(new:2, rejected:0) + psql QueueItems=2 → logout → 401. В конце — остановка приложения, порт :5080 свободен,
pipeline-строки очищены.
## Решения и отклонения
- **Приёмка отсева/return/clear на реальных записях — Task 13**: отсев наполняет pump, а pump вызывают
admin/tick (Task 10) и фоновый цикл (Task 11) — в Task 9 их нет, поэтому curl проверяет эндпоинты уровня
обработки (формы ответов/404/ok-пути на пустом отсеве). Сквозная карточка после ingest — тоже T13.
- **Дубль/no-op в ответе ingest — `id:null` + счётчики очереди** (Ruling 10 форма `{ok,id,queue}` без
доп.полей): «очередь не растёт» читается по `queue.total`, как в Acceptance плана.
- **Дефолты query-параметров** (`limit=100`, `offset=0`): параметры объявлены nullable (`int?`) с подстановкой
дефолта в эндпоинте — в кодовой базе нет хендлеров с C#-дефолтами перед HttpContext (ограничение языка);
clamp 1..500/≥0 остаётся в сервисе (T5), эхо offset/limit — из `RejectedPageDto`.
- **DELETE /rejected/{id} без 404** — 1:1 с delete_one L196198 и планом Task 9 L444 («прототип всегда ok,
404 не шлём»).
- **Текст 404 «Запись не найдена»** — константа слоя эндпоинтов (сервис возвращает null, как
CardsService → LeadsEndpoints).
- Кириллица в теле curl: inline `-d` с UTF-8 ломает тело на Windows/MSYS (квирк, отмечен в отчётах этапов
ранее) — в скрипте все тела через `--data @файл`.
## Concerns для Task 10/11
- T10: admin/tick вызовет `PumpOnceAsync` и вернёт pipeline-словарь + new_lead по `CreatedCards` — демо-ingest
станет сквозным (карточка/отсев из очереди T9-приёмки).
- T11: фоновый цикл (2 с) разберёт очередь сам; режешь «стоп-фраза» из приёмки T9 уйдёт в отсев stop c kw —
шаг return/clear на реальных записях закроется в T13.
- `PipelineProcessingService`/`PipelineIngestService` зарегистрированы scoped через `AddPipelineModule`
резолвятся только после 401-гейта (вне tenant-запроса scoped-зависимости не разрешимы) — паттерн соблюдён
во всех 6 + 1 новых хендлерах.
Скрипт приёмки оставлен: `.superpowers/sdd/deal-stage4-pipeline/task-9-curl-acceptance.sh` (+ .log рядом).