Обновить доки по закрытию остатков код-стайла
Аудит §2 переписан под решения (var-гейт, LF, дедуп закрыт — дублей нет); backlog: TD-COMMENTS-IFACE п.1–3 закрыты, TD-STYLE-ANALYZERS закрыт; STATUS.md — новый блок захода, устаревший блок «Осталось (в backlog)» в шапке удалён; план и ledger захода.
This commit is contained in:
@@ -1,58 +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 на ИИ-пути L1108–1111). Отчёт: 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
|
||||
# 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 на ИИ-пути L1108–1111). Отчёт: 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
|
||||
|
||||
@@ -1,55 +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` повторный старт — чисто. Это артефакт порядка команд, не кода.
|
||||
# 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` повторный старт — чисто. Это артефакт порядка команд, не кода.
|
||||
|
||||
@@ -1,203 +1,203 @@
|
||||
#!/usr/bin/env sh
|
||||
# Task 10 curl-приёмка: POST /api/admin/tick реальный (pipeline+pump+purge) и POST /api/admin/fts/rebuild на :5080
|
||||
# (план Task 10 L462–480; 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
|
||||
#!/usr/bin/env sh
|
||||
# Task 10 curl-приёмка: POST /api/admin/tick реальный (pipeline+pump+purge) и POST /api/admin/fts/rebuild на :5080
|
||||
# (план Task 10 L462–480; 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
|
||||
|
||||
@@ -1,68 +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 (L462–480), Rulings 6/8/9/10; прототип —
|
||||
dashboard_routes.py L261–264/L327–337, leads.py tick_storage/notify L454–504, fts.py rebuild L48–67.
|
||||
|
||||
## Файлы
|
||||
|
||||
### Создан — `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 L327–337: `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 L503–504
|
||||
(правка, обещанная 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.
|
||||
# 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 (L462–480), Rulings 6/8/9/10; прототип —
|
||||
dashboard_routes.py L261–264/L327–337, leads.py tick_storage/notify L454–504, fts.py rebuild L48–67.
|
||||
|
||||
## Файлы
|
||||
|
||||
### Создан — `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 L327–337: `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 L503–504
|
||||
(правка, обещанная 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.
|
||||
|
||||
@@ -1,251 +1,251 @@
|
||||
#!/usr/bin/env sh
|
||||
# Task 11 curl-приёмка: фоновый цикл pump (2 с) + фоновая автоочистка отсева (30 с тик) на :5080
|
||||
# (план Task 11 L482–501; 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
|
||||
#!/usr/bin/env sh
|
||||
# Task 11 curl-приёмка: фоновый цикл pump (2 с) + фоновая автоочистка отсева (30 с тик) на :5080
|
||||
# (план Task 11 L482–501; 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
|
||||
|
||||
@@ -1,76 +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 (L482–501),
|
||||
Rulings 8/9/11; прототип — main.py `_pipeline_loop` L79–88 / `_storage_loop` L43–53, pipeline.py L38–42 (lock), L901–902.
|
||||
|
||||
## Файлы
|
||||
|
||||
### Создан — `Deal.Api/PipelinePumpGate.cs`
|
||||
Общий воркер-гейт pump тенанта (Ruling 8; аналог asyncio.Lock pipeline.py L38–42): 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 L485–493): результат вливается в `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 L485–493 внутри
|
||||
_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).
|
||||
# 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 (L482–501),
|
||||
Rulings 8/9/11; прототип — main.py `_pipeline_loop` L79–88 / `_storage_loop` L43–53, pipeline.py L38–42 (lock), L901–902.
|
||||
|
||||
## Файлы
|
||||
|
||||
### Создан — `Deal.Api/PipelinePumpGate.cs`
|
||||
Общий воркер-гейт pump тенанта (Ruling 8; аналог asyncio.Lock pipeline.py L38–42): 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 L485–493): результат вливается в `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 L485–493 внутри
|
||||
_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).
|
||||
|
||||
@@ -1,268 +1,268 @@
|
||||
#!/usr/bin/env sh
|
||||
# Task 12 curl-приёмка: FTS-поиск карточек GET /api/search на :5080
|
||||
# (план Task 12 L501–517; Ruling 6; leads.py search L509–551). Сценарий: чистка 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
|
||||
#!/usr/bin/env sh
|
||||
# Task 12 curl-приёмка: FTS-поиск карточек GET /api/search на :5080
|
||||
# (план Task 12 L501–517; Ruling 6; leads.py search L509–551). Сценарий: чистка 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
|
||||
|
||||
@@ -1,69 +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 (L501–517), Ruling 6; прототип —
|
||||
leads.py search L509–551, 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).
|
||||
# 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 (L501–517), Ruling 6; прототип —
|
||||
leads.py search L509–551, 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).
|
||||
|
||||
@@ -1,476 +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-FTS∪LIKE на реальных
|
||||
# записях отсева — закрывается здесь). Сценарий:
|
||||
# 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
|
||||
#!/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-FTS∪LIKE на реальных
|
||||
# записях отсева — закрывается здесь). Сценарий:
|
||||
# 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
|
||||
|
||||
@@ -1,94 +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 (L519–542) + Self-Review;
|
||||
Rulings 2/6/8/9/10/11; закрыт T9-concern («return/dup-400/DELETE/clear/q-FTS∪LIKE на реальных
|
||||
записях отсева» — ранее эндпоинты проверялись на пустом отсеве, 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 перенесён в «Выполнено»
|
||||
(задачи 1–13, 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 может начинаться с чистого состояния.
|
||||
# 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 (L519–542) + Self-Review;
|
||||
Rulings 2/6/8/9/10/11; закрыт T9-concern («return/dup-400/DELETE/clear/q-FTS∪LIKE на реальных
|
||||
записях отсева» — ранее эндпоинты проверялись на пустом отсеве, 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 перенесён в «Выполнено»
|
||||
(задачи 1–13, 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 может начинаться с чистого состояния.
|
||||
|
||||
@@ -1,52 +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 L308–313 / команды 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 L53–85) | dialogId + плоские ch-поля + msgId/text/msgAt(epoch-ms, null→now)/force |
|
||||
| `RejectRecord.cs` | Команда записи отсева (processing.record L66–101) | 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 L26–46: stage→stageLabel (length→«короткое сообщение», …, dup→«повтор», 10), source→sourceLabel (stop→«правила», ml→«ML», ai→«ИИ», stale|dup→«система», 5); методы StageLabel/SourceLabel с фолбэками как prototype L40–45; `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 порта.
|
||||
# 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 L308–313 / команды 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 L53–85) | dialogId + плоские ch-поля + msgId/text/msgAt(epoch-ms, null→now)/force |
|
||||
| `RejectRecord.cs` | Команда записи отсева (processing.record L66–101) | 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 L26–46: stage→stageLabel (length→«короткое сообщение», …, dup→«повтор», 10), source→sourceLabel (stop→«правила», ml→«ML», ai→«ИИ», stale|dup→«система», 5); методы StageLabel/SourceLabel с фолбэками как prototype L40–45; `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 порта.
|
||||
|
||||
@@ -1,81 +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 (L300–325), 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 L40–45).
|
||||
- **Raw SQL** там, где EF-выражений нет: `UpsertAsync` — `INSERT … ON CONFLICT ("Id") DO UPDATE SET …` 1:1 с
|
||||
processing.record L79–101 (обновляются только поля прототипа; аудит возврата 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, L947–949).
|
||||
**Нюанс**: колонки `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.
|
||||
# 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 (L300–325), 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 L40–45).
|
||||
- **Raw SQL** там, где EF-выражений нет: `UpsertAsync` — `INSERT … ON CONFLICT ("Id") DO UPDATE SET …` 1:1 с
|
||||
processing.record L79–101 (обновляются только поля прототипа; аудит возврата 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, L947–949).
|
||||
**Нюанс**: колонки `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.
|
||||
|
||||
@@ -1,48 +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 (L327–349), Rulings 4/7;
|
||||
источники — pipeline.py (L131–341, L591–798), ai.py normalize_dedup (L261–267), правила/AmountParser Kanban.
|
||||
|
||||
## Файлы
|
||||
|
||||
### Созданы — ядро разбора (`Deal.Modules.Pipeline/Application/Parse/`, 1 тип = 1 файл, XML-doc, ссылки на строки прототипа)
|
||||
|
||||
| Файл | Назначение (1:1 с прототипом) |
|
||||
|---|---|
|
||||
| `MessageTextCleaner.cs` | clean_short/clean_block L148–193 + регэкспы/эмодзи-диапазоны L131–145 + `CleanLine` (_clean_line L682–683). Все шаги в порядке python: markdown-пары/ссылки → голые URL → защита «C#» → zero-width/nbsp → символы markdown → эмодзи (по кодовым точкам, суррогатные пары целиком) → маркеры списков → схлопывание → обрезка краёв → лимит по границе (L184–192) с «…». Внутренние rune-счётчики `CountCodePoints`/`SliceCodePoints` (python `len`/`[:n]` — кодовые точки, без разрыва пар). |
|
||||
| `MessageListNormalizer.cs` | normalize_list L317–329 (строка/список; запятая НЕ разделитель), normalize_stack L332–341 (≤12, одиночные буквы — мимо), стоп-слова стека L604–610 (`StackStopWords`). |
|
||||
| `ContactsQualifier.cs` | qualify_contact/build_contacts/primary_contact L344–430 + _norm_phone L661–663 + _contacts_from L666–679. Выход — `CardContactDto` Kanban ({type, value}; tg/phone/email/linkedin/whatsapp/site). Ограничения/наборы: боты, t.me-сервисы, «постовые» сайты (teletype.in и т.п.), ≤6, дедуп по casefold. |
|
||||
| `DedupHasher.cs` | normalize_dedup ai.py L261–267: только `\w`-символы (буквы/цифры/`_`, по кодовым точкам) + lowercase → **SHA1** hex. Детерминирован, инвариантен к регистру/пунктуации/пробелам. |
|
||||
| `SummaryComposer.cs` | compose_summary L225–284 (блоки Компания→Формат→О задаче→Требования[≤14]→Будет плюсом[≤10]→Условия, 1:1 cardPrompt) + _local_summary L294–314 + футер-хинты L288–291 (приватные — единственный потребитель). |
|
||||
| `LocalFieldsParser.cs` | _local_fields L718–798 + _field_of L686–698 + метки L591–596 + токены/стоп-слова L597–612 + 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, бюджет 1500–2000$ → 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 2–3 и ai.py L266–267 (`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 L604–610).
|
||||
- Семантика 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 L137–145; в .NET — ручной проход по Rune, а не regex по UTF-16).
|
||||
- Ограничения-числа вынесены в именованные константы; обрезки строк — rune-safe (`SliceCodePoints`), чтобы не разрывать суррогатные пары.
|
||||
# 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 (L327–349), Rulings 4/7;
|
||||
источники — pipeline.py (L131–341, L591–798), ai.py normalize_dedup (L261–267), правила/AmountParser Kanban.
|
||||
|
||||
## Файлы
|
||||
|
||||
### Созданы — ядро разбора (`Deal.Modules.Pipeline/Application/Parse/`, 1 тип = 1 файл, XML-doc, ссылки на строки прототипа)
|
||||
|
||||
| Файл | Назначение (1:1 с прототипом) |
|
||||
|---|---|
|
||||
| `MessageTextCleaner.cs` | clean_short/clean_block L148–193 + регэкспы/эмодзи-диапазоны L131–145 + `CleanLine` (_clean_line L682–683). Все шаги в порядке python: markdown-пары/ссылки → голые URL → защита «C#» → zero-width/nbsp → символы markdown → эмодзи (по кодовым точкам, суррогатные пары целиком) → маркеры списков → схлопывание → обрезка краёв → лимит по границе (L184–192) с «…». Внутренние rune-счётчики `CountCodePoints`/`SliceCodePoints` (python `len`/`[:n]` — кодовые точки, без разрыва пар). |
|
||||
| `MessageListNormalizer.cs` | normalize_list L317–329 (строка/список; запятая НЕ разделитель), normalize_stack L332–341 (≤12, одиночные буквы — мимо), стоп-слова стека L604–610 (`StackStopWords`). |
|
||||
| `ContactsQualifier.cs` | qualify_contact/build_contacts/primary_contact L344–430 + _norm_phone L661–663 + _contacts_from L666–679. Выход — `CardContactDto` Kanban ({type, value}; tg/phone/email/linkedin/whatsapp/site). Ограничения/наборы: боты, t.me-сервисы, «постовые» сайты (teletype.in и т.п.), ≤6, дедуп по casefold. |
|
||||
| `DedupHasher.cs` | normalize_dedup ai.py L261–267: только `\w`-символы (буквы/цифры/`_`, по кодовым точкам) + lowercase → **SHA1** hex. Детерминирован, инвариантен к регистру/пунктуации/пробелам. |
|
||||
| `SummaryComposer.cs` | compose_summary L225–284 (блоки Компания→Формат→О задаче→Требования[≤14]→Будет плюсом[≤10]→Условия, 1:1 cardPrompt) + _local_summary L294–314 + футер-хинты L288–291 (приватные — единственный потребитель). |
|
||||
| `LocalFieldsParser.cs` | _local_fields L718–798 + _field_of L686–698 + метки L591–596 + токены/стоп-слова L597–612 + 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, бюджет 1500–2000$ → 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 2–3 и ai.py L266–267 (`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 L604–610).
|
||||
- Семантика 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 L137–145; в .NET — ручной проход по Rune, а не regex по UTF-16).
|
||||
- Ограничения-числа вынесены в именованные константы; обрезки строк — rune-safe (`SliceCodePoints`), чтобы не разрывать суррогатные пары.
|
||||
|
||||
@@ -1,51 +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 (L350–368), Rulings 2/8/10;
|
||||
источники — pipeline.py enqueue (L53–85), processing.py (L66–193, L201–320), processing_routes.py (L17–74),
|
||||
api-map §3.6 (L178–186) и §4.5 (L306–313).
|
||||
|
||||
## Файлы
|
||||
|
||||
### Созданы — модуль Pipeline (`Deal.Modules.Pipeline/Application/`, 1 тип = 1 файл, XML-doc, ссылки на строки прототипа)
|
||||
|
||||
| Файл | Назначение (1:1 с прототипом) |
|
||||
|---|---|
|
||||
| `PipelineIngestService.cs` | Приём входящих — `EnqueueAsync(QueuedMessage, ct)` (Ruling 2, enqueue L53–85): 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 L66–101: пустой текст no-op, text[:6000]/reason[:500]/kw[:200], hue-дефолт #666, детерминированный id `r_<dialog>_<msgId>` через `RejectRecord.DeterministicId`, upsert — не дубликат); `ListQueueAsync` (list_queue L218–241, CreatedAt ASC, clamp 1..500); `QueueCountsAsync` (queue_counts L207–215: new/ai=filtered/total); `RejectedCountAsync`; `ListRejectedAsync` (list_rejected L246–312: 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 L128–193, детали ниже); `DeleteAsync`/`ClearAsync`/`PurgeExpiredAsync` (3 суток через `PipelineRejectConstants.RetentionDays`, purge_expired L104–117); `StatsAsync` (форма `/pipeline/stats`: {queue:{new,ai,total}, rejected}). 400-тексты возврата — public-константы (паттерн CardsService), их читают тесты и эндпоинты T9. |
|
||||
| `PipelineQueueStatuses.cs` | Статусы очереди new/filtered (pipeline.py ST_NEW/ST_AI L42–44) — первые потребители появились в 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 L128–193)
|
||||
|
||||
- Порядок проверок как в прототипе: записи нет → 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` (запись НЕ удаляется).
|
||||
- Две ветки очереди как в прототипе (L161–192): dialog+msgId есть → полный путь `PipelineIngestService.EnqueueAsync` (дубль-гвард сохраняется, force=true, msgAt = row.msg_at or now); иначе (старые записи без dialog/msgId) → прямая вставка строки p_/new/force (L174–192), 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). Отсев в боковой панели не показывается — счётчики/эндпоинты по плану сохранены.
|
||||
# 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 (L350–368), Rulings 2/8/10;
|
||||
источники — pipeline.py enqueue (L53–85), processing.py (L66–193, L201–320), processing_routes.py (L17–74),
|
||||
api-map §3.6 (L178–186) и §4.5 (L306–313).
|
||||
|
||||
## Файлы
|
||||
|
||||
### Созданы — модуль Pipeline (`Deal.Modules.Pipeline/Application/`, 1 тип = 1 файл, XML-doc, ссылки на строки прототипа)
|
||||
|
||||
| Файл | Назначение (1:1 с прототипом) |
|
||||
|---|---|
|
||||
| `PipelineIngestService.cs` | Приём входящих — `EnqueueAsync(QueuedMessage, ct)` (Ruling 2, enqueue L53–85): 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 L66–101: пустой текст no-op, text[:6000]/reason[:500]/kw[:200], hue-дефолт #666, детерминированный id `r_<dialog>_<msgId>` через `RejectRecord.DeterministicId`, upsert — не дубликат); `ListQueueAsync` (list_queue L218–241, CreatedAt ASC, clamp 1..500); `QueueCountsAsync` (queue_counts L207–215: new/ai=filtered/total); `RejectedCountAsync`; `ListRejectedAsync` (list_rejected L246–312: 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 L128–193, детали ниже); `DeleteAsync`/`ClearAsync`/`PurgeExpiredAsync` (3 суток через `PipelineRejectConstants.RetentionDays`, purge_expired L104–117); `StatsAsync` (форма `/pipeline/stats`: {queue:{new,ai,total}, rejected}). 400-тексты возврата — public-константы (паттерн CardsService), их читают тесты и эндпоинты T9. |
|
||||
| `PipelineQueueStatuses.cs` | Статусы очереди new/filtered (pipeline.py ST_NEW/ST_AI L42–44) — первые потребители появились в 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 L128–193)
|
||||
|
||||
- Порядок проверок как в прототипе: записи нет → 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` (запись НЕ удаляется).
|
||||
- Две ветки очереди как в прототипе (L161–192): dialog+msgId есть → полный путь `PipelineIngestService.EnqueueAsync` (дубль-гвард сохраняется, force=true, msgAt = row.msg_at or now); иначе (старые записи без dialog/msgId) → прямая вставка строки p_/new/force (L174–192), 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). Отсев в боковой панели не показывается — счётчики/эндпоинты по плану сохранены.
|
||||
|
||||
@@ -1,68 +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 (L370–388), Rulings 5/7/10;
|
||||
источники — ai.py filter_incoming/classify (L188–258), pipeline.py _local_fields (L718–798) и compose_summary
|
||||
(L225–284), ТЗ §5 (L104–106), эталон 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 L188–198 (pass/reason/skipped). |
|
||||
| `Models/AiBudgetDto.cs` | `{From?, To?, Cur}` — нормализованный бюджет (clean_budget L316–326; одна сумма → 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 L264–268, локальный путь T6/T7). |
|
||||
|
||||
### Создан — реализация (`Deal.Infrastructure/Integrations/LocalAiClassifier.cs`)
|
||||
- Фильтр **всегда** `{pass:true, reason:null, skipped:true}` — реального ИИ-фильтра нет (Ruling 5); выключатель
|
||||
aiFilterEnabled НЕ читается (ветки выключателя — у воркера T8, filter_incoming L190–192 и L1103–1106).
|
||||
- Классификатор: `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 L389–421, ≤6, дедуп,
|
||||
боты/сервисные t.me/«постовые» сайты отброшены); is_vacancy — маркерная гипотеза hireMarkers;
|
||||
**is_vacancy_known=false, board=null** («смысловые колонки до ИИ не назначаем», python L954–958/L796–797);
|
||||
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; бюджет 1500–2000$ → `{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 L264–268 возвращает 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)» реализуется в воркере
|
||||
(порт исключений не бросает).
|
||||
# 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 (L370–388), Rulings 5/7/10;
|
||||
источники — ai.py filter_incoming/classify (L188–258), pipeline.py _local_fields (L718–798) и compose_summary
|
||||
(L225–284), ТЗ §5 (L104–106), эталон 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 L188–198 (pass/reason/skipped). |
|
||||
| `Models/AiBudgetDto.cs` | `{From?, To?, Cur}` — нормализованный бюджет (clean_budget L316–326; одна сумма → 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 L264–268, локальный путь T6/T7). |
|
||||
|
||||
### Создан — реализация (`Deal.Infrastructure/Integrations/LocalAiClassifier.cs`)
|
||||
- Фильтр **всегда** `{pass:true, reason:null, skipped:true}` — реального ИИ-фильтра нет (Ruling 5); выключатель
|
||||
aiFilterEnabled НЕ читается (ветки выключателя — у воркера T8, filter_incoming L190–192 и L1103–1106).
|
||||
- Классификатор: `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 L389–421, ≤6, дедуп,
|
||||
боты/сервисные t.me/«постовые» сайты отброшены); is_vacancy — маркерная гипотеза hireMarkers;
|
||||
**is_vacancy_known=false, board=null** («смысловые колонки до ИИ не назначаем», python L954–958/L796–797);
|
||||
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; бюджет 1500–2000$ → `{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 L264–268 возвращает 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)» реализуется в воркере
|
||||
(порт исключений не бросает).
|
||||
|
||||
@@ -1,77 +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 (L390–410), Rulings 3/4/8/12;
|
||||
источники — python pipeline.py `_store_lead` (L433–514), rules.py board_accepts/hits (L251–319), compose_summary
|
||||
(L225–284), build_contacts/primary_contact (L389–430); эталоны — 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` L433–514): 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` для прошедшей доски, иначе пусто (L449–450/L473). |
|
||||
| `PipelineCardWriter.cs` | Тонкая обёртка создания (L400–402): `PrefixId.New(KanbanIdPrefixes.Card)` (id `l_`, генератор владельца) → `CardComposer.BuildAsync` → `IKanjStore.AddCardAsync(snapshot)` → `IPipelineStore.LinkAsync(hash, cardId)` (порядок AddCard → Link, L512–513) → `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 с L512–513, сбой 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.
|
||||
# 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 (L390–410), Rulings 3/4/8/12;
|
||||
источники — python pipeline.py `_store_lead` (L433–514), rules.py board_accepts/hits (L251–319), compose_summary
|
||||
(L225–284), build_contacts/primary_contact (L389–430); эталоны — 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` L433–514): 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` для прошедшей доски, иначе пусто (L449–450/L473). |
|
||||
| `PipelineCardWriter.cs` | Тонкая обёртка создания (L400–402): `PrefixId.New(KanbanIdPrefixes.Card)` (id `l_`, генератор владельца) → `CardComposer.BuildAsync` → `IKanjStore.AddCardAsync(snapshot)` → `IPipelineStore.LinkAsync(hash, cardId)` (порядок AddCard → Link, L512–513) → `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 с L512–513, сбой 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.
|
||||
|
||||
@@ -1,96 +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 (L412–435), Rulings 2/4/5/8/9;
|
||||
источники — python `_pump_unlocked` (pipeline.py L920–1183), `_skip_no_budget` (L196–218), `_local_fields`
|
||||
(L718–798), ml_client.py (AI_WEIGHT/track_decisions/is_enabled L26–27/L153–161), ai.py filter_incoming (L188–198).
|
||||
|
||||
## Файлы
|
||||
|
||||
### Создан — модуль Pipeline (`Deal.Modules.Pipeline/Application/`, 1 тип = 1 файл, XML-doc, порядок строго 1:1)
|
||||
| Файл | Назначение |
|
||||
|---|---|
|
||||
| `PipelineWorkerService.cs` | Чистый оркестратор pump — `PumpOnceAsync` = `_pump_unlocked` L920–1183. «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, L1117–1121) → отсев `{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 L101–107 —
|
||||
сбой → «не уверен»; 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 L216–218:
|
||||
clean_budget(raw.budget) + extract_amounts(text)) — фильтр и композитор смотрят в один источник сумм.
|
||||
- **force** = `[JsonIgnore] QueueItemDto.Force` (T2): минует правила/stale/ML (L963–965) и ИИ-фильтр (L1097–1100),
|
||||
no-budget (L1143); вердикт «спам» ИИ отменяется (L1117–1121) — покрыто тестами 11 и 14. Дедуп для force НЕ
|
||||
пропускается (1:1 L940–951).
|
||||
- **Возврат результата вместо публикации**: 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 L80–87); после сбоя строка с claim останется и будет обработана/отсеяна следующим тиком (прототип-квирк).
|
||||
- Регистрация воркера уже в `AddPipelineModule`; внешние порты (IMlClient/IAiClassifier/IPipelineStore/IKanjStore/
|
||||
ISettingsStore) регистрируются AddDealPersistence/AddDealIntegrations (T3/T6) — в DI-графе Program.cs появится после
|
||||
T9.
|
||||
|
||||
## Fix по ревью — стемп `is_vacancy_known` после успешной ИИ-классификации
|
||||
|
||||
**Замечание (Important):** python `_pump_unlocked` L1108–1111 ставит `raw["is_vacancy_known"] = True` ПОСЛЕ любого
|
||||
успешного classify на ИИ-пути (raw непуст) и ДО создания карточки (L1148). В первой версии стемп стоял только в
|
||||
ML-ветках, из-за чего на ИИ-пути тип никогда не был «подтверждён» (LocalAiClassifier возвращает known=false) —
|
||||
обучающие push `t:hire`/`t:order` (Ruling 8, L1175–1180) в проде были недостижимы, позитивных тестов обучения у
|
||||
`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 → L1176–1180).
|
||||
|
||||
**Проверка (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 с прототипом), а не
|
||||
классификатора.
|
||||
# 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 (L412–435), Rulings 2/4/5/8/9;
|
||||
источники — python `_pump_unlocked` (pipeline.py L920–1183), `_skip_no_budget` (L196–218), `_local_fields`
|
||||
(L718–798), ml_client.py (AI_WEIGHT/track_decisions/is_enabled L26–27/L153–161), ai.py filter_incoming (L188–198).
|
||||
|
||||
## Файлы
|
||||
|
||||
### Создан — модуль Pipeline (`Deal.Modules.Pipeline/Application/`, 1 тип = 1 файл, XML-doc, порядок строго 1:1)
|
||||
| Файл | Назначение |
|
||||
|---|---|
|
||||
| `PipelineWorkerService.cs` | Чистый оркестратор pump — `PumpOnceAsync` = `_pump_unlocked` L920–1183. «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, L1117–1121) → отсев `{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 L101–107 —
|
||||
сбой → «не уверен»; 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 L216–218:
|
||||
clean_budget(raw.budget) + extract_amounts(text)) — фильтр и композитор смотрят в один источник сумм.
|
||||
- **force** = `[JsonIgnore] QueueItemDto.Force` (T2): минует правила/stale/ML (L963–965) и ИИ-фильтр (L1097–1100),
|
||||
no-budget (L1143); вердикт «спам» ИИ отменяется (L1117–1121) — покрыто тестами 11 и 14. Дедуп для force НЕ
|
||||
пропускается (1:1 L940–951).
|
||||
- **Возврат результата вместо публикации**: 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 L80–87); после сбоя строка с claim останется и будет обработана/отсеяна следующим тиком (прототип-квирк).
|
||||
- Регистрация воркера уже в `AddPipelineModule`; внешние порты (IMlClient/IAiClassifier/IPipelineStore/IKanjStore/
|
||||
ISettingsStore) регистрируются AddDealPersistence/AddDealIntegrations (T3/T6) — в DI-графе Program.cs появится после
|
||||
T9.
|
||||
|
||||
## Fix по ревью — стемп `is_vacancy_known` после успешной ИИ-классификации
|
||||
|
||||
**Замечание (Important):** python `_pump_unlocked` L1108–1111 ставит `raw["is_vacancy_known"] = True` ПОСЛЕ любого
|
||||
успешного classify на ИИ-пути (raw непуст) и ДО создания карточки (L1148). В первой версии стемп стоял только в
|
||||
ML-ветках, из-за чего на ИИ-пути тип никогда не был «подтверждён» (LocalAiClassifier возвращает known=false) —
|
||||
обучающие push `t:hire`/`t:order` (Ruling 8, L1175–1180) в проде были недостижимы, позитивных тестов обучения у
|
||||
`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 → L1176–1180).
|
||||
|
||||
**Проверка (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 с прототипом), а не
|
||||
классификатора.
|
||||
|
||||
@@ -1,236 +1,236 @@
|
||||
#!/usr/bin/env sh
|
||||
# Task 9 curl-приёмка: эндпоинты /api/pipeline/* + POST /api/demo/ingest на :5080 (план Task 9 L458–460;
|
||||
# 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
|
||||
#!/usr/bin/env sh
|
||||
# Task 9 curl-приёмка: эндпоинты /api/pipeline/* + POST /api/demo/ingest на :5080 (план Task 9 L458–460;
|
||||
# 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
|
||||
|
||||
@@ -1,71 +1,71 @@
|
||||
# Task 9 — «Эндпоинты /api/pipeline/* + /api/demo/ingest + DI + curl-приёмка» — отчёт
|
||||
|
||||
Статус: **DONE**. Сборка 0 warnings / 0 errors; тесты 521/521 PASS (регрессии нет, новых unit-тестов не
|
||||
требовалось — endpoint-слои покрываются curl, план Task 9 L453–454); curl-приёмка на :5080 — 22/22 PASS.
|
||||
План: `docs/superpowers/plans/2026-09-05-deal-stage4-pipeline.md` Task 9 (L437–460), Rulings 2/6/10/11;
|
||||
контракт — api-map §3.6 L178–186, §4.5 L308–315; прототип — processing_routes.py L17–74 (processing.py
|
||||
L128–320 — сервис 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 L196–198, 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 L196–198 и планом 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 рядом).
|
||||
# Task 9 — «Эндпоинты /api/pipeline/* + /api/demo/ingest + DI + curl-приёмка» — отчёт
|
||||
|
||||
Статус: **DONE**. Сборка 0 warnings / 0 errors; тесты 521/521 PASS (регрессии нет, новых unit-тестов не
|
||||
требовалось — endpoint-слои покрываются curl, план Task 9 L453–454); curl-приёмка на :5080 — 22/22 PASS.
|
||||
План: `docs/superpowers/plans/2026-09-05-deal-stage4-pipeline.md` Task 9 (L437–460), Rulings 2/6/10/11;
|
||||
контракт — api-map §3.6 L178–186, §4.5 L308–315; прототип — processing_routes.py L17–74 (processing.py
|
||||
L128–320 — сервис 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 L196–198, 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 L196–198 и планом 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 рядом).
|
||||
|
||||
Reference in New Issue
Block a user