ktalk-cli 2.0.0__tar.gz → 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/PKG-INFO +55 -7
  2. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/README.md +54 -6
  3. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/pyproject.toml +1 -1
  4. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/cli_content.py +36 -17
  5. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/formatters.py +32 -4
  6. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_nfr17_identity_verification.py +294 -20
  7. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/.gitignore +0 -0
  8. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/LICENSE +0 -0
  9. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/CLAUDE.md +0 -0
  10. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/__init__.py +0 -0
  11. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/auth.py +0 -0
  12. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/calendar_reader.py +0 -0
  13. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/cli.py +0 -0
  14. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/cli_contacts.py +0 -0
  15. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/cli_meeting.py +0 -0
  16. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/cli_meeting_args.py +0 -0
  17. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/cli_meeting_confirm.py +0 -0
  18. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/cli_meetings_read.py +0 -0
  19. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/cli_sanction.py +0 -0
  20. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/cli_store.py +0 -0
  21. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/cli_sync.py +0 -0
  22. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/cli_token.py +0 -0
  23. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/client.py +0 -0
  24. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/config.py +0 -0
  25. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/confirmation.py +0 -0
  26. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/contacts.py +0 -0
  27. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/contour_diagnostics.py +0 -0
  28. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/download.py +0 -0
  29. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/endpoints.py +0 -0
  30. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/enrichment.py +0 -0
  31. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/host_config.py +0 -0
  32. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/meeting_body.py +0 -0
  33. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/meeting_cancel.py +0 -0
  34. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/meeting_scheduling.py +0 -0
  35. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/pagination.py +0 -0
  36. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/reconciliation.py +0 -0
  37. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/registry.py +0 -0
  38. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/rooms.py +0 -0
  39. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/store.py +0 -0
  40. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/store_migration.py +0 -0
  41. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/token_file.py +0 -0
  42. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/transcript_identity.py +0 -0
  43. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/write_journal.py +0 -0
  44. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/src/ktalk_cli/write_sanction.py +0 -0
  45. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/conftest.py +0 -0
  46. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/fixtures/access-info-expired.json +0 -0
  47. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/fixtures/access-info-full.json +0 -0
  48. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/fixtures/archive-8col.md +0 -0
  49. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/fixtures/archive-dup-id.md +0 -0
  50. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/fixtures/archive-escaped-pipe-7col.md +0 -0
  51. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/fixtures/archive-page1.json +0 -0
  52. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/fixtures/archive-page2-empty.json +0 -0
  53. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/fixtures/archive-pipe-in-name.md +0 -0
  54. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/fixtures/calendar-item-session.json +0 -0
  55. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/fixtures/chat-messages-session.json +0 -0
  56. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/fixtures/conference-history-session-oversized.json +0 -0
  57. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/fixtures/conference-history-session.json +0 -0
  58. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/fixtures/error-body-problem-json.json +0 -0
  59. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/fixtures/error-body-validation.json +0 -0
  60. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/fixtures/recording-detail-session-full-participants.json +0 -0
  61. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/fixtures/recording-detail-session-oversized-partial.json +0 -0
  62. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/fixtures/recording-detail-with-qualities.json +0 -0
  63. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/fixtures/recording-list-item-session.json +0 -0
  64. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/fixtures/recordings-list-apikey-page1.json +0 -0
  65. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/fixtures/recordings-list-apikey-page2.json +0 -0
  66. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/fixtures/registry-archive-2026-04.md +0 -0
  67. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/fixtures/registry.md +0 -0
  68. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/fixtures/room-detail-session.json +0 -0
  69. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_adr022_ktalk_cli_rename.py +0 -0
  70. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_archive.py +0 -0
  71. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_auth_modes.py +0 -0
  72. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_calendar.py +0 -0
  73. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_chat.py +0 -0
  74. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_cli.py +0 -0
  75. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_cli_config_show.py +0 -0
  76. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_cli_content.py +0 -0
  77. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_cli_export_store_isolation.py +0 -0
  78. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_cli_host_config_wiring.py +0 -0
  79. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_cli_meeting.py +0 -0
  80. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_cli_meeting_sanctioned.py +0 -0
  81. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_cli_meetings_surface.py +0 -0
  82. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_cli_migrate_to_central_store.py +0 -0
  83. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_client.py +0 -0
  84. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_concurrency.py +0 -0
  85. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_config.py +0 -0
  86. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_confirmation.py +0 -0
  87. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_contour_diagnostics.py +0 -0
  88. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_diagnostics.py +0 -0
  89. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_download.py +0 -0
  90. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_enrichment.py +0 -0
  91. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_formatters.py +0 -0
  92. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_fr19_auth_status.py +0 -0
  93. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_fr21_no_vault_layout.py +0 -0
  94. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_fr39_calendar_inclusive_end.py +0 -0
  95. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_fr40_timezone_format.py +0 -0
  96. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_fr41_last_synced.py +0 -0
  97. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_host_config.py +0 -0
  98. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_issue3_null_id_fallback.py +0 -0
  99. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_meeting_body.py +0 -0
  100. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_meeting_cancel.py +0 -0
  101. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_meeting_scheduling.py +0 -0
  102. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_migration.py +0 -0
  103. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_pagination.py +0 -0
  104. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_reconciliation.py +0 -0
  105. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_registry.py +0 -0
  106. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_rooms.py +0 -0
  107. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_search_contacts.py +0 -0
  108. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_secret_masking.py +0 -0
  109. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_store.py +0 -0
  110. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_store_migration.py +0 -0
  111. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_token_file.py +0 -0
  112. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_write_journal.py +0 -0
  113. {ktalk_cli-2.0.0 → ktalk_cli-2.1.0}/tests/test_write_sanction.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ktalk-cli
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: CLI for accessing Kontur Talk (KTalk) recordings, transcripts and summaries
5
5
  Project-URL: Homepage, https://github.com/mdemyanov/ktalk-cli
6
6
  Project-URL: Repository, https://github.com/mdemyanov/ktalk-cli
@@ -26,15 +26,18 @@ Description-Content-Type: text/markdown
26
26
  [![PyPI](https://img.shields.io/pypi/v/ktalk-cli)](https://pypi.org/project/ktalk-cli/)
27
27
  [![Python](https://img.shields.io/pypi/pyversions/ktalk-cli)](https://pypi.org/project/ktalk-cli/)
28
28
 
29
- CLI `ktalk` для доступа к записям [Контур.Толк](https://ktalk.ru) (KTalk) — чтение
30
- записей, транскриптов и саммари, работа с расписанием, операционный реестр
31
- обработки записей на SQLite.
29
+ CLI `ktalk` — интерфейс командной строки для тех, кто работает с записями видеовстреч
30
+ [Контур.Толк](https://ktalk.ru) (KTalk) программно: читает записи, транскрипты и саммари,
31
+ управляет расписанием, ведёт локальный реестр обработки записей на SQLite. Годится и как
32
+ самостоятельный инструмент, и как предусловие плагина Claude Code `ktalk` — подробнее в
33
+ разделе «Пакет и плагин Claude Code» ниже.
32
34
 
33
35
  > Раньше пакет назывался `ktalk-mcp` и, помимо CLI, поднимал MCP-сервер для
34
36
  > Claude Code (инструменты вида `ktalk_list_recordings`). Этот слой снят
35
37
  > целиком — MCP в пакете больше нет, единственная точка входа — команда
36
- > `ktalk`. Нужна интеграция с Claude Code — отдельный плагин `ktalk` вызывает
37
- > эту же CLI напрямую, без MCP-сервера.
38
+ > `ktalk`. Пришли по старой ссылке или ищете `ktalk-mcp` — это тот же проект
39
+ > под новым именем, старый пакет дальше не развивается (о конфликте имени
40
+ > команды при апгрейде — ниже, в «Установке»).
38
41
 
39
42
  Умеет:
40
43
  - Список записей конференций и детали одной записи.
@@ -73,6 +76,18 @@ uv tool uninstall ktalk-mcp
73
76
  uv tool install ktalk-cli
74
77
  ```
75
78
 
79
+ **Проверка версии** — после установки или обновления:
80
+
81
+ ```bash
82
+ ktalk --version # печатает, например: ktalk-cli 2.1.0
83
+ ```
84
+
85
+ **Обновление** до последней версии — та же команда `install`, только `upgrade`:
86
+
87
+ ```bash
88
+ uv tool upgrade ktalk-cli
89
+ ```
90
+
76
91
  ## Авторизация
77
92
 
78
93
  CLI поддерживает два способа авторизации: session token (кука браузера) и
@@ -222,11 +237,20 @@ ktalk auth-status
222
237
  Все команды поддерживают `--json` (валидный JSON в stdout; ошибки — в stderr с
223
238
  ненулевым кодом возврата).
224
239
 
240
+ ### Коды возврата
241
+
242
+ | Код | Значение |
243
+ |---|---|
244
+ | `0` | Успех. |
245
+ | `1` | Отказ вызова — сеть, сервер, конфигурация. |
246
+ | `2` | Usage error — неверные аргументы CLI (`argparse`). |
247
+ | `3` | Только `ktalk get-transcript`. Данные получены и напечатаны полностью, но независимая сверка идентичности не сошлась (`identity_check.result == "mismatch"`) — состав участников транскрипта разошёлся с составом записи. Это не сбой команды: код 3 отличает «данные есть, но сверка не сошлась» от `0` (сошлось или не проверялось) и от `1`/`2` (данных нет вовсе). Подробности — в самом теле ответа, поле `identity_check` (ADR-024 §Д1). |
248
+
225
249
  | Команда | Назначение |
226
250
  |---|---|
227
251
  | `ktalk list-recordings [--query Q] [--start-from ISO] [--start-to ISO] [--top N] [--order O] [--page-token T]` | Список записей. `--top` 1–1000 (по умолчанию 30); `--order`: `byTimeNewFirst` (умолчание), `byTimeOldFirst`, `byTitle`, `bySizeBigFirst`, `bySizeSmallFirst`. |
228
252
  | `ktalk get-recording <recording_key>` | Детали записи — автор, дата, длительность, участники (список ограничен 6, полный состав — `get-participants`). |
229
- | `ktalk get-transcript <recording_key> [--chunk N] [--chunk-size N]` | Транскрипт по спикерам с таймкодами. Длинный транскрипт режется на чанки по границам реплик: `--chunk 0` (умолчание) — целиком или первый чанк; `--chunk-size` — макс. символов в чанке (умолчание 30000, ~7500 токенов). |
253
+ | `ktalk get-transcript <recording_key> [--chunk N] [--chunk-size N]` | Транскрипт по спикерам с таймкодами. Длинный транскрипт режется на чанки по границам реплик: `--chunk 0` (умолчание) — целиком или первый чанк; `--chunk-size` — макс. символов в чанке (умолчание 30000, ~7500 токенов). Независимая сверка идентичности включена по умолчанию (`--no-verify-identity` отключает); `--chunk` вне диапазона сверку по сети не запускает вовсе, `identity_check.result == "not_checked"`/`reason: "chunk_out_of_range"`. |
230
254
  | `ktalk get-summary <recording_key>` | Полное саммари (краткое резюме + протокол). |
231
255
  | `ktalk get-summary-type <recording_key> --type shortSummary\|protocol` | Саммари одного типа. |
232
256
  | `ktalk get-participants <recording_key>` | Полный состав участников, включая анонимных — обходит лимит в 6, который отдают `get-recording`/`list-recordings`. |
@@ -386,6 +410,30 @@ uv run ruff check .
386
410
  KTALK_SESSION_TOKEN=... KTALK_BASE_URL=... uv run ktalk auth-status
387
411
  ```
388
412
 
413
+ ## Пакет и плагин Claude Code
414
+
415
+ `ktalk-cli` работает и сам по себе, и как предусловие плагина Claude Code `ktalk`. Плагин не
416
+ обращается к KTalk напрямую и не поднимает MCP-сервер — он вызывает эту же команду `ktalk`
417
+ как единственную точку входа в контур.
418
+
419
+ Плагин пинует точную версию пакета (не нижний порог: «ровно эта версия», не «эта или новее») в
420
+ собственном файле совместимости. Если что-то в интеграции с плагином ведёт себя не так, как
421
+ описано в его документации, — первым делом сверьте версию:
422
+
423
+ ```bash
424
+ ktalk --version # см. «Проверка версии» в разделе «Установка»
425
+ ```
426
+
427
+ Версия не совпадает с той, что требует плагин, — обновите пакет тем же способом, что при
428
+ установке (`uv tool upgrade ktalk-cli`, см. «Установка»); не совпадает в другую сторону
429
+ (пакет новее, чем ожидает плагин) — не откатывайте его самостоятельно, сверьтесь с тем, кто
430
+ настраивал плагин.
431
+
432
+ ## Проблемы и вопросы
433
+
434
+ Нашли баг, некорректное поведение или неточность в документации — заведите issue в этом
435
+ репозитории: https://github.com/mdemyanov/ktalk-cli/issues.
436
+
389
437
  ## Лицензия
390
438
 
391
439
  MIT
@@ -3,15 +3,18 @@
3
3
  [![PyPI](https://img.shields.io/pypi/v/ktalk-cli)](https://pypi.org/project/ktalk-cli/)
4
4
  [![Python](https://img.shields.io/pypi/pyversions/ktalk-cli)](https://pypi.org/project/ktalk-cli/)
5
5
 
6
- CLI `ktalk` для доступа к записям [Контур.Толк](https://ktalk.ru) (KTalk) — чтение
7
- записей, транскриптов и саммари, работа с расписанием, операционный реестр
8
- обработки записей на SQLite.
6
+ CLI `ktalk` — интерфейс командной строки для тех, кто работает с записями видеовстреч
7
+ [Контур.Толк](https://ktalk.ru) (KTalk) программно: читает записи, транскрипты и саммари,
8
+ управляет расписанием, ведёт локальный реестр обработки записей на SQLite. Годится и как
9
+ самостоятельный инструмент, и как предусловие плагина Claude Code `ktalk` — подробнее в
10
+ разделе «Пакет и плагин Claude Code» ниже.
9
11
 
10
12
  > Раньше пакет назывался `ktalk-mcp` и, помимо CLI, поднимал MCP-сервер для
11
13
  > Claude Code (инструменты вида `ktalk_list_recordings`). Этот слой снят
12
14
  > целиком — MCP в пакете больше нет, единственная точка входа — команда
13
- > `ktalk`. Нужна интеграция с Claude Code — отдельный плагин `ktalk` вызывает
14
- > эту же CLI напрямую, без MCP-сервера.
15
+ > `ktalk`. Пришли по старой ссылке или ищете `ktalk-mcp` — это тот же проект
16
+ > под новым именем, старый пакет дальше не развивается (о конфликте имени
17
+ > команды при апгрейде — ниже, в «Установке»).
15
18
 
16
19
  Умеет:
17
20
  - Список записей конференций и детали одной записи.
@@ -50,6 +53,18 @@ uv tool uninstall ktalk-mcp
50
53
  uv tool install ktalk-cli
51
54
  ```
52
55
 
56
+ **Проверка версии** — после установки или обновления:
57
+
58
+ ```bash
59
+ ktalk --version # печатает, например: ktalk-cli 2.1.0
60
+ ```
61
+
62
+ **Обновление** до последней версии — та же команда `install`, только `upgrade`:
63
+
64
+ ```bash
65
+ uv tool upgrade ktalk-cli
66
+ ```
67
+
53
68
  ## Авторизация
54
69
 
55
70
  CLI поддерживает два способа авторизации: session token (кука браузера) и
@@ -199,11 +214,20 @@ ktalk auth-status
199
214
  Все команды поддерживают `--json` (валидный JSON в stdout; ошибки — в stderr с
200
215
  ненулевым кодом возврата).
201
216
 
217
+ ### Коды возврата
218
+
219
+ | Код | Значение |
220
+ |---|---|
221
+ | `0` | Успех. |
222
+ | `1` | Отказ вызова — сеть, сервер, конфигурация. |
223
+ | `2` | Usage error — неверные аргументы CLI (`argparse`). |
224
+ | `3` | Только `ktalk get-transcript`. Данные получены и напечатаны полностью, но независимая сверка идентичности не сошлась (`identity_check.result == "mismatch"`) — состав участников транскрипта разошёлся с составом записи. Это не сбой команды: код 3 отличает «данные есть, но сверка не сошлась» от `0` (сошлось или не проверялось) и от `1`/`2` (данных нет вовсе). Подробности — в самом теле ответа, поле `identity_check` (ADR-024 §Д1). |
225
+
202
226
  | Команда | Назначение |
203
227
  |---|---|
204
228
  | `ktalk list-recordings [--query Q] [--start-from ISO] [--start-to ISO] [--top N] [--order O] [--page-token T]` | Список записей. `--top` 1–1000 (по умолчанию 30); `--order`: `byTimeNewFirst` (умолчание), `byTimeOldFirst`, `byTitle`, `bySizeBigFirst`, `bySizeSmallFirst`. |
205
229
  | `ktalk get-recording <recording_key>` | Детали записи — автор, дата, длительность, участники (список ограничен 6, полный состав — `get-participants`). |
206
- | `ktalk get-transcript <recording_key> [--chunk N] [--chunk-size N]` | Транскрипт по спикерам с таймкодами. Длинный транскрипт режется на чанки по границам реплик: `--chunk 0` (умолчание) — целиком или первый чанк; `--chunk-size` — макс. символов в чанке (умолчание 30000, ~7500 токенов). |
230
+ | `ktalk get-transcript <recording_key> [--chunk N] [--chunk-size N]` | Транскрипт по спикерам с таймкодами. Длинный транскрипт режется на чанки по границам реплик: `--chunk 0` (умолчание) — целиком или первый чанк; `--chunk-size` — макс. символов в чанке (умолчание 30000, ~7500 токенов). Независимая сверка идентичности включена по умолчанию (`--no-verify-identity` отключает); `--chunk` вне диапазона сверку по сети не запускает вовсе, `identity_check.result == "not_checked"`/`reason: "chunk_out_of_range"`. |
207
231
  | `ktalk get-summary <recording_key>` | Полное саммари (краткое резюме + протокол). |
208
232
  | `ktalk get-summary-type <recording_key> --type shortSummary\|protocol` | Саммари одного типа. |
209
233
  | `ktalk get-participants <recording_key>` | Полный состав участников, включая анонимных — обходит лимит в 6, который отдают `get-recording`/`list-recordings`. |
@@ -363,6 +387,30 @@ uv run ruff check .
363
387
  KTALK_SESSION_TOKEN=... KTALK_BASE_URL=... uv run ktalk auth-status
364
388
  ```
365
389
 
390
+ ## Пакет и плагин Claude Code
391
+
392
+ `ktalk-cli` работает и сам по себе, и как предусловие плагина Claude Code `ktalk`. Плагин не
393
+ обращается к KTalk напрямую и не поднимает MCP-сервер — он вызывает эту же команду `ktalk`
394
+ как единственную точку входа в контур.
395
+
396
+ Плагин пинует точную версию пакета (не нижний порог: «ровно эта версия», не «эта или новее») в
397
+ собственном файле совместимости. Если что-то в интеграции с плагином ведёт себя не так, как
398
+ описано в его документации, — первым делом сверьте версию:
399
+
400
+ ```bash
401
+ ktalk --version # см. «Проверка версии» в разделе «Установка»
402
+ ```
403
+
404
+ Версия не совпадает с той, что требует плагин, — обновите пакет тем же способом, что при
405
+ установке (`uv tool upgrade ktalk-cli`, см. «Установка»); не совпадает в другую сторону
406
+ (пакет новее, чем ожидает плагин) — не откатывайте его самостоятельно, сверьтесь с тем, кто
407
+ настраивал плагин.
408
+
409
+ ## Проблемы и вопросы
410
+
411
+ Нашли баг, некорректное поведение или неточность в документации — заведите issue в этом
412
+ репозитории: https://github.com/mdemyanov/ktalk-cli/issues.
413
+
366
414
  ## Лицензия
367
415
 
368
416
  MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ktalk-cli"
3
- version = "2.0.0"
3
+ version = "2.1.0"
4
4
  description = "CLI for accessing Kontur Talk (KTalk) recordings, transcripts and summaries"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -27,6 +27,7 @@ from ktalk_cli.formatters import (
27
27
  format_summary_by_type,
28
28
  render_tool_output,
29
29
  render_transcript_output,
30
+ resolve_chunk_range,
30
31
  )
31
32
  from ktalk_cli.transcript_identity import check_identity
32
33
 
@@ -132,11 +133,13 @@ async def _verify_transcript_identity(client: KTalkClient, recording_key: str, t
132
133
 
133
134
 
134
135
  def _render_transcript_with_identity(
135
- output_text: str, identity_check: dict | None, *, json_flag: bool
136
+ output_text: str, identity_check: dict | None, *, json_flag: bool, in_range: bool = True
136
137
  ) -> str:
137
- """Сборка вывода `get-transcript` (companion-спека «Оркестрация», шаг 4).
138
+ """Сборка вывода `get-transcript` (companion-спека «Оркестрация», шаг 5).
138
139
  `identity_check is None` -> сверка отключена (`--no-verify-identity`), выводим
139
- как раньше."""
140
+ как раньше. `in_range` — валидность `--chunk` уже известна вызывающей стороне
141
+ (`resolve_chunk_range`, ADR-024 §Д3) — на пути вне диапазона JSON-конверт
142
+ собирается явно (`{"error": …}`), без `try/except JSONDecodeError`."""
140
143
  if identity_check is None:
141
144
  return output_text
142
145
 
@@ -146,37 +149,53 @@ def _render_transcript_with_identity(
146
149
  line += f" ({identity_check['reason']})"
147
150
  return f"{output_text}\n{line}"
148
151
 
149
- try:
150
- parsed = json.loads(output_text)
151
- except (json.JSONDecodeError, ValueError):
152
- # `--chunk N` вне диапазона отдаёт нестрого-JSON текст — сверять нечего,
153
- # печатаем как есть (companion-спека, edge case чанкинга).
154
- return output_text
152
+ if not in_range:
153
+ return json.dumps(
154
+ {"error": output_text, "identity_check": identity_check},
155
+ ensure_ascii=False,
156
+ indent=2,
157
+ )
155
158
 
159
+ parsed = json.loads(output_text)
156
160
  return json.dumps(
157
161
  {"transcript": parsed, "identity_check": identity_check}, ensure_ascii=False, indent=2
158
162
  )
159
163
 
160
164
 
161
165
  def cmd_get_transcript(_reg, args: argparse.Namespace) -> int:
162
- async def _fetch() -> tuple[dict, dict | None]:
166
+ fmt = "raw" if args.json else "markdown"
167
+
168
+ async def _fetch() -> tuple[dict, dict | None, bool]:
163
169
  async with KTalkClient.from_settings(Settings()) as client:
164
170
  data = await client.get_transcript(args.recording_key)
171
+ in_range, _total_chunks = resolve_chunk_range(data, fmt, args.chunk, args.chunk_size)
165
172
  identity_check = None
166
173
  if not args.no_verify_identity:
167
- identity_check = await _verify_transcript_identity(
168
- client, args.recording_key, data
169
- )
170
- return data, identity_check
174
+ if in_range:
175
+ identity_check = await _verify_transcript_identity(
176
+ client, args.recording_key, data
177
+ )
178
+ else:
179
+ # ADR-024 §Д3: чанк заведомо вне диапазона — сверка не
180
+ # оплачивается сетевым вызовом, `not_checked` формируется
181
+ # локально, переиспользуя словарь исходов ADR-023.
182
+ identity_check = {"result": "not_checked", "reason": "chunk_out_of_range"}
183
+ return data, identity_check, in_range
171
184
 
172
185
  try:
173
- data, identity_check = asyncio.run(_fetch())
186
+ data, identity_check, in_range = asyncio.run(_fetch())
174
187
  except Exception as exc: # noqa: BLE001 - surface as CLI error, NFR-5 маскирует
175
188
  print(f"Ошибка: {redact_secrets(str(exc))}", file=sys.stderr)
176
189
  return 1
177
- fmt = "raw" if args.json else "markdown"
178
190
  output_text = render_transcript_output(data, fmt, args.chunk, args.chunk_size)
179
- print(_render_transcript_with_identity(output_text, identity_check, json_flag=args.json))
191
+ print(
192
+ _render_transcript_with_identity(
193
+ output_text, identity_check, json_flag=args.json, in_range=in_range
194
+ )
195
+ )
196
+ if identity_check is not None and identity_check.get("result") == "mismatch":
197
+ # ADR-024 §Д1: отказ становится громким — код 3, отдельный от 0/1/2.
198
+ return 3
180
199
  return 0
181
200
 
182
201
 
@@ -318,6 +318,37 @@ def chunk_transcript_raw(data: dict, chunk_size: int) -> list[str]:
318
318
  return chunks
319
319
 
320
320
 
321
+ def _chunk_transcript(data: dict, fmt: str, chunk_size: int, full_text: str) -> list[str]:
322
+ """Общая точка чанкинга для `render_transcript_output`/`resolve_chunk_range` —
323
+ не дублирует ветвление raw/markdown (ADR-024 companion-спека §3)."""
324
+ if fmt == "raw":
325
+ return chunk_transcript_raw(data, chunk_size)
326
+ return chunk_transcript_markdown(full_text, chunk_size)
327
+
328
+
329
+ def resolve_chunk_range(data: dict, fmt: str, chunk: int, chunk_size: int) -> tuple[bool, int]:
330
+ """Определяет, лежит ли `chunk` в валидном диапазоне для `data`, БЕЗ сети
331
+ (ADR-024 §Д3, issue #9) — переиспользует чанкинг `render_transcript_output`,
332
+ не дублирует его. Отрицательный `chunk` — тоже вне диапазона (не Python-
333
+ отрицательная индексация).
334
+
335
+ Возвращает `(in_range, total_chunks)`.
336
+ """
337
+ if fmt == "raw":
338
+ full_text = format_raw(data)
339
+ else:
340
+ full_text = format_transcript(data)
341
+
342
+ if chunk == 0 and len(full_text) <= chunk_size:
343
+ return True, 1
344
+
345
+ chunks = _chunk_transcript(data, fmt, chunk_size, full_text)
346
+ total_chunks = len(chunks)
347
+ chunk_index = 0 if chunk == 0 else chunk - 1
348
+
349
+ return 0 <= chunk_index < total_chunks, total_chunks
350
+
351
+
321
352
  def render_transcript_output(data: dict, fmt: str, chunk: int, chunk_size: int) -> str:
322
353
  """Общий слой чтения транскрипта с чанкингом — единственная точка правды для
323
354
  `ktalk_get_transcript` (MCP) и `ktalk get-transcript` (CLI, DEV-002 волны 3):
@@ -332,10 +363,7 @@ def render_transcript_output(data: dict, fmt: str, chunk: int, chunk_size: int)
332
363
  if chunk == 0 and total_characters <= chunk_size:
333
364
  return full_text
334
365
 
335
- if fmt == "raw":
336
- chunks = chunk_transcript_raw(data, chunk_size)
337
- else:
338
- chunks = chunk_transcript_markdown(full_text, chunk_size)
366
+ chunks = _chunk_transcript(data, fmt, chunk_size, full_text)
339
367
 
340
368
  total_chunks = len(chunks)
341
369
  chunk_index = 0 if chunk == 0 else chunk - 1
@@ -1,7 +1,8 @@
1
1
  """AT-design: NFR-17 — обнаружимость подмены транскрипта под конкуренцией
2
- (`content/40-architecture/at-design-adr023-open-issues.md`, раздел «NFR-17»).
2
+ (`content/40-architecture/at-design-adr023-open-issues.md`, раздел «NFR-17»;
3
+ волна 2 (ADR-024, Д1/Д3) — `content/40-architecture/at-design-adr024-open-issues-wave2.md`).
3
4
 
4
- Покрывает 3 AC NFR-17 из `content/30-requirements/transcript-identity-observability.md`
5
+ Покрывает 5 AC NFR-17 из `content/30-requirements/transcript-identity-observability.md`
5
6
  (BA-014, вход RES-006) через `#### Scenario:` капабилити-спеки
6
7
  `openspec/specs/recording-data-access/spec.md`, раздел «A transcript response's
7
8
  recording identity is independently verifiable, not assumed from a successful call»:
@@ -11,6 +12,21 @@ recording identity is independently verifiable, not assumed from a successful ca
11
12
  - NFR17-AC3 — недоступность независимого источника отдаёт явный сигнал
12
13
  «не сверено» (`not_checked`), не тихое умолчание о совпадении; основной результат
13
14
  транскрипта возвращается в любом случае.
15
+ - NFR17-AC4 (ADR-024 Д1, issue #5, новый сценарий капабилити-спеки «A detected
16
+ mismatch fails loudly, not silently») — `mismatch` обязан завершать `get-transcript`
17
+ кодом возврата 3, отдельным от 0 (успех), 1 (отказ вызова) и 2 (usage error);
18
+ тело ответа (`transcript`+`identity_check`) не пустеет. Причина исходной подмены
19
+ этим НЕ устраняется (ADR-024 §Д1) — тест проверяет только громкость отказа.
20
+ - NFR17-AC5 (ADR-024 Д3, issue #9, новый сценарий «An out-of-range chunk request
21
+ does not silently drop the verification signal») — `--chunk` вне диапазона
22
+ обязан (а) не платить сетевым вызовом `get_recording` вовсе, (б) отдавать явный
23
+ `identity_check.result == "not_checked"`, `reason == "chunk_out_of_range"` в
24
+ валидном `--json`-конверте, отличимо от «сверка прошла и совпало».
25
+
26
+ ADR-024 Д2 (issue #8) не меняет код — устойчивость `anonymousId` подтверждена
27
+ измерением на живом контуре (companion-спека §2), не тестом; регрессия ключа
28
+ сравнения анонимов уже покрыта ниже
29
+ (`test_nfr17_boundary_anonymous_participants_identified_by_anonymous_id`).
14
30
 
15
31
  Решение SA, ПОПРАВЛЕННОЕ владельцем на gate-sa (ADR-023, ред. 2a2f6e3, НЕ 81b379a):
16
32
  сверка ВКЛЮЧЕНА ПО УМОЛЧАНИЮ у `get-transcript`; флаг `--no-verify-identity` её
@@ -50,6 +66,26 @@ NFR-17»): `match` (пересечение непусто), `mismatch` (оба
50
66
  усиленные тесты ниже проверяют явно: (а) поле `identity_check` не пропущено, (б)
51
67
  результат не «match» по умолчанию при отказе, (в) `--no-verify-identity` даёт
52
68
  ИМЕННО отсутствие второго вызова, а не тихий always-on второй вызов вопреки флагу.
69
+ - **NFR17-AC4 (Д1), испорченный ввод** — N/A, явно, не по умолчанию: `mismatch` —
70
+ сигнатура серверной кросс-контаминации (RES-006), не опечатки `recording_key`.
71
+ Опечатанный ключ, для которого сервер отдаёт СОГЛАСОВАННЫЙ (тот же неверный ключ)
72
+ ответ и на транскрипт, и на `get_recording`, даёт `match` на СВОЁМ (чужом) составе,
73
+ не `mismatch` — нет воспроизводимого триггера подмены со стороны клиентского ввода
74
+ (ADR-024 §Д1: причина не локализована, тест на неё не проектируется).
75
+ - **NFR17-AC4 (Д1), замаскированный отказ** — это и есть самый предмет решения:
76
+ до ADR-024 код возврата 0 на `mismatch` МАСКИРОВАЛ обнаруженное расхождение от
77
+ потребителей, читающих только код (не тело ответа). `test_nfr17_ac4_…` проверяет
78
+ явно, что маска снята: `rc == 3`, отдельно от 0/1/2.
79
+ - **NFR17-AC5 (Д3), испорченный ввод** — отрицательный номер чанка
80
+ (`--chunk -1`) — не просто «вне диапазона», а структурно бессмысленное значение,
81
+ которое пользователь мог ввести по опечатке; должно давать тот же исход
82
+ (`not_checked`/`chunk_out_of_range`, ноль сетевых вызовов), не отдельную ветку.
83
+ - **NFR17-AC5 (Д3), замаскированный отказ** — до ADR-024 результат сверки на этой
84
+ ветке ВЫЧИСЛЯЛСЯ (сетевой вызов оплачивался), но ТИХО терялся сборкой `--json`
85
+ (`JSONDecodeError` -> печать как есть, без `identity_check`) — неотличимо от
86
+ «сверка прошла успешно» на уровне общей формы ответа. Тесты проверяют явно:
87
+ ноль вызовов `get_recording` (не просто «результат не виден») и присутствие
88
+ структурированного `identity_check.reason == "chunk_out_of_range"` в JSON.
53
89
  """
54
90
 
55
91
  from __future__ import annotations
@@ -85,6 +121,13 @@ def _recording_url_re(key: str) -> re.Pattern:
85
121
  return re.compile(rf"{re.escape(BASE_URL)}/api/recordings/{re.escape(key)}(\?.*)?$")
86
122
 
87
123
 
124
+ def _count_recording_calls(httpx_mock: HTTPXMock, key: str) -> int:
125
+ """Число сетевых вызовов ИМЕННО `get_recording` (не транскрипта) — ADR-024 Д3
126
+ требует ровно 0 на `--chunk` вне диапазона, не «меньше вызовов вообще»."""
127
+ pattern = _recording_url_re(key)
128
+ return sum(1 for req in httpx_mock.get_requests() if pattern.search(str(req.url)))
129
+
130
+
88
131
  # ============================================================================================
89
132
  # Unit — transcript_identity.py (чистые функции, входы — фикстуры JSON)
90
133
  # ============================================================================================
@@ -326,12 +369,20 @@ def test_nfr17_no_verify_identity_flag_skips_second_call_entirely(
326
369
 
327
370
 
328
371
  @pytest.mark.httpx_mock(assert_all_responses_were_requested=False)
329
- def test_nfr17_ac1_mismatch_surfaced_in_default_on_json_response(
372
+ def test_nfr17_ac4_mismatch_exits_with_code_3_and_still_carries_full_body(
330
373
  httpx_mock: HTTPXMock, monkeypatch, capsys
331
374
  ):
332
- """NFR17-AC1 буквально: конкурентная подмена (расходящиеся составы) -> потребитель
333
- получает наблюдаемый признак ПРЕЖДЕ, чем содержимое использовано дальше — признак
334
- доступен уже в самом `--json`-ответе `get-transcript`, не требует отдельного вызова."""
375
+ """NFR17-AC1+AC4 (ADR-024 Д1, issue #5): конкурентная подмена (расходящиеся
376
+ составы) -> потребитель получает наблюдаемый признак ПРЕЖДЕ, чем содержимое
377
+ использовано дальше (признак доступен уже в самом `--json`-ответе, AC1), И код
378
+ возврата 3 — отдельный от 0/1/2 (AC4, ADR-024 Д1).
379
+
380
+ ПРАВКА КОНТРАКТА (не регресс, не находка QA-runner): до ADR-024 этот же тест
381
+ закреплял `assert rc == 0` на mismatch — ровно то тихое поведение, которое
382
+ решение Д1 отменяет («отказ становится громким», не «гонка устранена» —
383
+ ADR-024 §Д1 дословно). Старое утверждение: `assert rc == 0`. Новое: `assert
384
+ rc == 3`. Причина исходной подмены (issue #5) этим НЕ локализована — тест не
385
+ проверяет большего, чем громкость отказа."""
335
386
  key = "REC-1"
336
387
  httpx_mock.add_response(
337
388
  json={
@@ -350,11 +401,24 @@ def test_nfr17_ac1_mismatch_surfaced_in_default_on_json_response(
350
401
  rc = _run(["get-transcript", key, "--json"], monkeypatch)
351
402
  out = json.loads(capsys.readouterr().out)
352
403
 
353
- assert rc == 0
354
404
  assert out["identity_check"]["result"] == "mismatch", (
355
405
  f"NFR17-AC1: составы {{'u9'}} vs {{'u1'}} не пересекаются — ожидался mismatch "
356
406
  f"в самом --json ответе, получено {out.get('identity_check')}"
357
407
  )
408
+ assert rc == 3, (
409
+ "NFR17-AC4 (ADR-024 Д1, замаскированный отказ до правки): `mismatch` обязан "
410
+ f"завершать команду кодом 3, отдельным от 0/1/2 — фактический код {rc}. "
411
+ "Старый контракт (`rc == 0` даже на mismatch) отменён этим ADR — это НЕ "
412
+ "регресс покрытия, а сознательная правка теста под новое решение."
413
+ )
414
+ assert rc not in (0, 1, 2), (
415
+ f"NFR17-AC4: код 3 обязан быть отличим от успеха(0)/отказа вызова(1)/usage error(2), "
416
+ f"получено {rc}"
417
+ )
418
+ assert out.get("transcript") is not None, (
419
+ "NFR17-AC4 (замаскированный отказ): код 3 не должен опустошать тело ответа — "
420
+ f"`transcript` обязан присутствовать целиком, получено {out}"
421
+ )
358
422
 
359
423
 
360
424
  # ============================================================================================
@@ -427,26 +491,37 @@ def test_nfr17_malformed_mistyped_recording_key_get_recording_404_yields_not_che
427
491
 
428
492
 
429
493
  # ============================================================================================
430
- # Регресс — граница `--chunk` вне диапазона + умолчание-включено + `--json`
494
+ # NFR17-AC5 (ADR-024 Д3, issue #9) — `--chunk` вне диапазона: сверка не оплачивается,
495
+ # сигнал `not_checked`/`chunk_out_of_range` не теряется сборкой `--json`
431
496
  # ============================================================================================
432
497
 
433
498
 
434
499
  @pytest.mark.httpx_mock(assert_all_responses_were_requested=False)
435
- def test_nfr17_out_of_range_chunk_with_default_verify_does_not_crash_on_json_parse(
500
+ def test_nfr17_ac5_out_of_range_chunk_with_default_verify_skips_network_call_and_signals_not_checked(
436
501
  httpx_mock: HTTPXMock, monkeypatch, capsys
437
502
  ):
438
- """Companion-спека, «Оркестрация», edge case: `render_transcript_output` при
439
- `--chunk N` вне диапазона отдаёт НЕСТРОГО-JSON текст ("Чанк N не существует...")
440
- — `json.loads` этого текста бросает исключение; наивная реализация обёртки
441
- `{"transcript": parsed, "identity_check": ...}` крашится на этом пути. Ожидание:
442
- команда не падает (`rc == 0`), сообщение о несуществующем чанке доходит до
443
- пользователя как есть."""
503
+ """ПРАВКА КОНТРАКТА (не регресс — ADR-024 Д3 отменяет прежнее поведение,
504
+ называвшееся issue #9 недостаточным): старый тест
505
+ (`test_nfr17_out_of_range_chunk_with_default_verify_does_not_crash_on_json_parse`)
506
+ закреплял РОВНО ДВА сетевых вызова (транскрипт + `get_recording`) и печать
507
+ НЕСТРОГО-JSON текста "Чанк N не существует..." как есть — то самое тихое
508
+ выбрасывание уже вычисленного `identity_check`, которое Д3 устраняет. Старое
509
+ утверждение: `len(httpx_mock.get_requests()) == 2` и `rc == 0` без проверки формы
510
+ JSON. Новое: `get_recording` НЕ вызывается вовсе (0, не 2 вызова на сверку —
511
+ сетевая цена не оплачивается на заведомо неверном чанке), а `--json`-вывод —
512
+ валидный JSON с явным `identity_check.result == "not_checked"`,
513
+ `reason == "chunk_out_of_range"`, отличимым от `match`."""
444
514
  key = "REC-1"
445
515
  httpx_mock.add_response(
446
516
  json={"status": "complete", "tracks": []}, url=_transcript_url_re(key)
447
517
  )
518
+ # get_recording МОК присутствует специально: если реализация (сегодняшняя,
519
+ # непочиненная) всё же вызовет его — вызов должен УСПЕШНО пройти и быть
520
+ # засчитан `_count_recording_calls`, а не свалить тест сторонней ошибкой
521
+ # pytest_httpx «нет зарегистрированного ответа».
448
522
  httpx_mock.add_response(
449
- json={"id": key, "participants": []}, url=_recording_url_re(key)
523
+ json={"id": key, "participants": [{"userInfo": {"key": "u1"}}]},
524
+ url=_recording_url_re(key),
450
525
  )
451
526
 
452
527
  rc = _run(
@@ -454,12 +529,211 @@ def test_nfr17_out_of_range_chunk_with_default_verify_does_not_crash_on_json_par
454
529
  )
455
530
  raw = capsys.readouterr().out
456
531
 
457
- assert len(httpx_mock.get_requests()) == 2, (
458
- "NFR17 (edge case чанкинга, умолчание-включено): сверка обязана произойти "
459
- f"даже на этом пути (два вызова), фактически {len(httpx_mock.get_requests())}"
532
+ assert rc == 0, (
533
+ f"NFR17-AC5: чанк вне диапазона — usage error нет, команда не должна "
534
+ f"падать, stdout: {raw!r}"
535
+ )
536
+ assert _count_recording_calls(httpx_mock, key) == 0, (
537
+ "NFR17-AC5 (ADR-024 Д3): `--chunk` вне диапазона НЕ должен запускать сверку "
538
+ f"по сети вовсе — фактически вызовов get_recording: "
539
+ f"{_count_recording_calls(httpx_mock, key)}"
540
+ )
541
+
542
+ try:
543
+ out = json.loads(raw)
544
+ except json.JSONDecodeError:
545
+ pytest.fail(
546
+ "NFR17-AC5 (замаскированный отказ до правки): `--json`-вывод на чанке "
547
+ f"вне диапазона обязан быть валидным JSON-конвертом, а не нестрого-JSON "
548
+ f"текстом сообщения — получено {raw!r}"
549
+ )
550
+
551
+ identity_check = out.get("identity_check")
552
+ assert identity_check is not None, (
553
+ f"NFR17-AC5 (замаскированный отказ): `identity_check` не должен тихо "
554
+ f"пропадать при сборке `--json` на этой ветке, получено {out}"
555
+ )
556
+ assert identity_check["result"] == "not_checked", (
557
+ f"NFR17-AC5: чанк вне диапазона -> `not_checked`, получено {identity_check}"
558
+ )
559
+ assert identity_check.get("reason") == "chunk_out_of_range", (
560
+ "NFR17-AC5: причина обязана называть именно чанк вне диапазона (переиспользуя "
561
+ f"словарь исходов `not_checked`, ADR-024 §Д3), получено {identity_check}"
562
+ )
563
+ assert identity_check["result"] != "match", (
564
+ "NFR17-AC5 (замаскированный отказ): пропуск сверки не должен путаться с "
565
+ "успешным совпадением"
566
+ )
567
+ assert "error" in out and "99" in out["error"], (
568
+ f"сообщение о несуществующем чанке обязано дойти до пользователя под ключом "
569
+ f"`error`, получено {out}"
570
+ )
571
+
572
+
573
+ @pytest.mark.httpx_mock(assert_all_responses_were_requested=False)
574
+ def test_nfr17_malformed_negative_chunk_index_also_skips_network_call_and_signals_not_checked(
575
+ httpx_mock: HTTPXMock, monkeypatch, capsys
576
+ ):
577
+ """Класс «испорченный/опечатанный ввод» для NFR17-AC5: `--chunk -1` — не просто
578
+ большое число вне диапазона, а структурно бессмысленное значение (пользователь
579
+ мог опечататься, введя отрицательный номер) — обязано давать тот же честный
580
+ исход, что и положительное значение вне диапазона, не отдельную непроверенную
581
+ ветку (например, не должно случайно попасть в валидный `chunk_index` через
582
+ Python-семантику отрицательной индексации где-то в реализации)."""
583
+ key = "REC-1"
584
+ httpx_mock.add_response(
585
+ json={"status": "complete", "tracks": []}, url=_transcript_url_re(key)
586
+ )
587
+ httpx_mock.add_response(
588
+ json={"id": key, "participants": [{"userInfo": {"key": "u1"}}]},
589
+ url=_recording_url_re(key),
590
+ )
591
+
592
+ rc = _run(
593
+ ["get-transcript", key, "--json", "--chunk", "-1", "--chunk-size", "10"], monkeypatch
594
+ )
595
+ raw = capsys.readouterr().out
596
+
597
+ assert rc == 0
598
+ assert _count_recording_calls(httpx_mock, key) == 0, (
599
+ "NFR17-AC5 (испорченный ввод, отрицательный чанк): сверка не должна "
600
+ f"запускаться, фактически вызовов get_recording: "
601
+ f"{_count_recording_calls(httpx_mock, key)}"
602
+ )
603
+ out = json.loads(raw)
604
+ assert out.get("identity_check", {}).get("reason") == "chunk_out_of_range", (
605
+ f"отрицательный `--chunk` обязан классифицироваться как вне диапазона, "
606
+ f"получено {out}"
607
+ )
608
+
609
+
610
+ @pytest.mark.httpx_mock(assert_all_responses_were_requested=False)
611
+ def test_nfr17_out_of_range_chunk_with_no_verify_identity_is_unaffected_by_hardening(
612
+ httpx_mock: HTTPXMock, monkeypatch, capsys
613
+ ):
614
+ """Регресс-guard (companion-спека, «Edge cases»): `--no-verify-identity` +
615
+ `--chunk` вне диапазона — поведение НЕ меняется ADR-024 Д3 (сверка и без того
616
+ отключена явным флагом, `identity_check` не появляется вовсе); ожидание — уже
617
+ верно на сегодняшнем дереве, тест закрепляет это (green guard), не проектирует
618
+ новое поведение."""
619
+ key = "REC-1"
620
+ httpx_mock.add_response(
621
+ json={"status": "complete", "tracks": []}, url=_transcript_url_re(key)
622
+ )
623
+
624
+ rc = _run(
625
+ [
626
+ "get-transcript",
627
+ key,
628
+ "--json",
629
+ "--chunk",
630
+ "99",
631
+ "--chunk-size",
632
+ "10",
633
+ "--no-verify-identity",
634
+ ],
635
+ monkeypatch,
636
+ )
637
+ raw = capsys.readouterr().out
638
+
639
+ assert rc == 0
640
+ assert _count_recording_calls(httpx_mock, key) == 0, (
641
+ "`--no-verify-identity`: сверка не должна запускаться независимо от чанка"
642
+ )
643
+ assert "identity_check" not in raw, (
644
+ f"`--no-verify-identity`: `identity_check` не должен появляться вовсе, "
645
+ f"получено {raw!r}"
460
646
  )
461
- assert rc == 0, f"NFR17 (edge case чанкинга): команда не должна падать, stdout: {raw!r}"
462
647
  assert "не существует" in raw, (
463
648
  f"сообщение о несуществующем чанке должно дойти до пользователя как есть, "
464
649
  f"получено: {raw!r}"
465
650
  )
651
+
652
+
653
+ # ============================================================================================
654
+ # NFR17-AC5, unit — `formatters.resolve_chunk_range` (новая чистая функция, ADR-024 §3)
655
+ # ============================================================================================
656
+
657
+
658
+ def _five_entry_raw_transcript() -> dict:
659
+ """5 треков по одной реплике из 30 символов -> с `chunk_size=80` даёт РОВНО 5
660
+ raw-чанков (проверено на сегодняшнем `chunk_transcript_raw`, до правки —
661
+ фикстура объективна, не подогнана под ожидаемый ответ новой функции)."""
662
+ tracks = []
663
+ for i in range(5):
664
+ tracks.append(
665
+ {
666
+ "speaker": {"userInfo": {"key": f"u{i}"}, "isAnonymous": False},
667
+ "chunks": [{"startTimeOffsetInMillis": i * 1000, "text": "x" * 30}],
668
+ }
669
+ )
670
+ return {"status": "complete", "tracks": tracks}
671
+
672
+
673
+ def test_nfr17_ac5_resolve_chunk_range_reports_in_range_for_a_middle_chunk():
674
+ from ktalk_cli.formatters import resolve_chunk_range
675
+
676
+ data = _five_entry_raw_transcript()
677
+
678
+ in_range, total_chunks = resolve_chunk_range(data, "raw", 3, 80)
679
+
680
+ assert total_chunks == 5, f"фикстура даёт 5 raw-чанков, получено {total_chunks}"
681
+ assert in_range is True, "чанк 3 из 5 — валидный номер, ожидался in_range=True"
682
+
683
+
684
+ def test_nfr17_ac5_resolve_chunk_range_boundary_last_valid_chunk_is_in_range():
685
+ from ktalk_cli.formatters import resolve_chunk_range
686
+
687
+ data = _five_entry_raw_transcript()
688
+
689
+ in_range, total_chunks = resolve_chunk_range(data, "raw", 5, 80)
690
+
691
+ assert total_chunks == 5
692
+ assert in_range is True, (
693
+ "граница: последний валидный номер чанка (== total_chunks) обязан быть "
694
+ "in_range=True"
695
+ )
696
+
697
+
698
+ def test_nfr17_ac5_resolve_chunk_range_one_past_last_chunk_is_out_of_range():
699
+ from ktalk_cli.formatters import resolve_chunk_range
700
+
701
+ data = _five_entry_raw_transcript()
702
+
703
+ in_range, total_chunks = resolve_chunk_range(data, "raw", 6, 80)
704
+
705
+ assert total_chunks == 5
706
+ assert in_range is False, (
707
+ "граница: total_chunks + 1 обязан быть out_of_range — ровно этот случай "
708
+ "воспроизводит issue #9"
709
+ )
710
+
711
+
712
+ def test_nfr17_ac5_resolve_chunk_range_auto_chunk_zero_maps_to_first_chunk():
713
+ """`--chunk 0` (умолчание, «авто») -> при тексте длиннее `chunk_size` эквивалентен
714
+ первому чанку, та же семантика, что `render_transcript_output` сегодня
715
+ (`chunk_index = 0 if chunk == 0 else chunk - 1`) — новая функция не должна менять
716
+ этот выбор, только вынести его в переиспользуемую форму."""
717
+ from ktalk_cli.formatters import resolve_chunk_range
718
+
719
+ data = _five_entry_raw_transcript()
720
+
721
+ in_range, total_chunks = resolve_chunk_range(data, "raw", 0, 80)
722
+
723
+ assert total_chunks == 5
724
+ assert in_range is True, "`--chunk 0` при длинном тексте обязан маппиться на первый чанк"
725
+
726
+
727
+ def test_nfr17_malformed_resolve_chunk_range_negative_chunk_is_out_of_range():
728
+ """Класс «испорченный/опечатанный ввод» на уровне чистой функции: отрицательный
729
+ номер чанка — не «просто больше total_chunks», а сам по себе структурно
730
+ невалидный ввод; не должен случайно стать валидным через Python-семантику
731
+ отрицательной индексации где-то в реализации `resolve_chunk_range`."""
732
+ from ktalk_cli.formatters import resolve_chunk_range
733
+
734
+ data = _five_entry_raw_transcript()
735
+
736
+ in_range, total_chunks = resolve_chunk_range(data, "raw", -1, 80)
737
+
738
+ assert total_chunks == 5
739
+ assert in_range is False, f"отрицательный чанк обязан быть out_of_range, total_chunks={total_chunks}"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes