ktalk-cli 1.0.0__tar.gz → 2.0.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-1.0.0 → ktalk_cli-2.0.0}/PKG-INFO +1 -1
  2. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/pyproject.toml +1 -1
  3. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/cli.py +1 -1
  4. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/cli_content.py +58 -4
  5. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/formatters.py +2 -2
  6. ktalk_cli-2.0.0/src/ktalk_cli/transcript_identity.py +69 -0
  7. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_cli_content.py +8 -2
  8. ktalk_cli-2.0.0/tests/test_fr41_last_synced.py +243 -0
  9. ktalk_cli-2.0.0/tests/test_issue3_null_id_fallback.py +177 -0
  10. ktalk_cli-2.0.0/tests/test_nfr17_identity_verification.py +465 -0
  11. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/.gitignore +0 -0
  12. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/LICENSE +0 -0
  13. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/README.md +0 -0
  14. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/CLAUDE.md +0 -0
  15. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/__init__.py +0 -0
  16. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/auth.py +0 -0
  17. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/calendar_reader.py +0 -0
  18. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/cli_contacts.py +0 -0
  19. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/cli_meeting.py +0 -0
  20. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/cli_meeting_args.py +0 -0
  21. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/cli_meeting_confirm.py +0 -0
  22. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/cli_meetings_read.py +0 -0
  23. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/cli_sanction.py +0 -0
  24. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/cli_store.py +0 -0
  25. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/cli_sync.py +0 -0
  26. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/cli_token.py +0 -0
  27. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/client.py +0 -0
  28. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/config.py +0 -0
  29. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/confirmation.py +0 -0
  30. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/contacts.py +0 -0
  31. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/contour_diagnostics.py +0 -0
  32. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/download.py +0 -0
  33. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/endpoints.py +0 -0
  34. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/enrichment.py +0 -0
  35. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/host_config.py +0 -0
  36. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/meeting_body.py +0 -0
  37. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/meeting_cancel.py +0 -0
  38. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/meeting_scheduling.py +0 -0
  39. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/pagination.py +0 -0
  40. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/reconciliation.py +0 -0
  41. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/registry.py +0 -0
  42. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/rooms.py +0 -0
  43. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/store.py +0 -0
  44. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/store_migration.py +0 -0
  45. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/token_file.py +0 -0
  46. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/write_journal.py +0 -0
  47. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/write_sanction.py +0 -0
  48. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/conftest.py +0 -0
  49. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/access-info-expired.json +0 -0
  50. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/access-info-full.json +0 -0
  51. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/archive-8col.md +0 -0
  52. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/archive-dup-id.md +0 -0
  53. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/archive-escaped-pipe-7col.md +0 -0
  54. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/archive-page1.json +0 -0
  55. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/archive-page2-empty.json +0 -0
  56. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/archive-pipe-in-name.md +0 -0
  57. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/calendar-item-session.json +0 -0
  58. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/chat-messages-session.json +0 -0
  59. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/conference-history-session-oversized.json +0 -0
  60. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/conference-history-session.json +0 -0
  61. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/error-body-problem-json.json +0 -0
  62. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/error-body-validation.json +0 -0
  63. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/recording-detail-session-full-participants.json +0 -0
  64. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/recording-detail-session-oversized-partial.json +0 -0
  65. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/recording-detail-with-qualities.json +0 -0
  66. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/recording-list-item-session.json +0 -0
  67. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/recordings-list-apikey-page1.json +0 -0
  68. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/recordings-list-apikey-page2.json +0 -0
  69. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/registry-archive-2026-04.md +0 -0
  70. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/registry.md +0 -0
  71. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/room-detail-session.json +0 -0
  72. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_adr022_ktalk_cli_rename.py +0 -0
  73. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_archive.py +0 -0
  74. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_auth_modes.py +0 -0
  75. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_calendar.py +0 -0
  76. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_chat.py +0 -0
  77. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_cli.py +0 -0
  78. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_cli_config_show.py +0 -0
  79. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_cli_export_store_isolation.py +0 -0
  80. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_cli_host_config_wiring.py +0 -0
  81. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_cli_meeting.py +0 -0
  82. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_cli_meeting_sanctioned.py +0 -0
  83. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_cli_meetings_surface.py +0 -0
  84. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_cli_migrate_to_central_store.py +0 -0
  85. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_client.py +0 -0
  86. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_concurrency.py +0 -0
  87. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_config.py +0 -0
  88. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_confirmation.py +0 -0
  89. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_contour_diagnostics.py +0 -0
  90. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_diagnostics.py +0 -0
  91. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_download.py +0 -0
  92. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_enrichment.py +0 -0
  93. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_formatters.py +0 -0
  94. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_fr19_auth_status.py +0 -0
  95. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_fr21_no_vault_layout.py +0 -0
  96. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_fr39_calendar_inclusive_end.py +0 -0
  97. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_fr40_timezone_format.py +0 -0
  98. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_host_config.py +0 -0
  99. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_meeting_body.py +0 -0
  100. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_meeting_cancel.py +0 -0
  101. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_meeting_scheduling.py +0 -0
  102. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_migration.py +0 -0
  103. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_pagination.py +0 -0
  104. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_reconciliation.py +0 -0
  105. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_registry.py +0 -0
  106. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_rooms.py +0 -0
  107. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_search_contacts.py +0 -0
  108. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_secret_masking.py +0 -0
  109. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_store.py +0 -0
  110. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_store_migration.py +0 -0
  111. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_token_file.py +0 -0
  112. {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_write_journal.py +0 -0
  113. {ktalk_cli-1.0.0 → ktalk_cli-2.0.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: 1.0.0
3
+ Version: 2.0.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ktalk-cli"
3
- version = "1.0.0"
3
+ version = "2.0.0"
4
4
  description = "CLI for accessing Kontur Talk (KTalk) recordings, transcripts and summaries"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -212,7 +212,7 @@ def _cmd_dashboard(reg: Registry, args) -> int:
212
212
  new = [r for r in recs if r["status"] == "new"]
213
213
  stats = {s: sum(1 for r in recs if r["status"] == s) for s in _STATUSES}
214
214
  if args.json:
215
- _print_json({"new": new, "stats": stats})
215
+ _print_json({"new": new, "stats": stats, "last_synced": reg.get_meta("last_synced")})
216
216
  return 0
217
217
  print("# Дашборд KTalk\n")
218
218
  print("## Новые записи")
@@ -11,6 +11,7 @@ from __future__ import annotations
11
11
 
12
12
  import argparse
13
13
  import asyncio
14
+ import json
14
15
  import sys
15
16
  from collections.abc import Awaitable, Callable
16
17
 
@@ -27,6 +28,7 @@ from ktalk_cli.formatters import (
27
28
  render_tool_output,
28
29
  render_transcript_output,
29
30
  )
31
+ from ktalk_cli.transcript_identity import check_identity
30
32
 
31
33
 
32
34
  def register_subparsers(sub) -> None:
@@ -48,6 +50,12 @@ def register_subparsers(sub) -> None:
48
50
  p_tr.add_argument("--chunk", type=int, default=0)
49
51
  p_tr.add_argument("--chunk-size", type=int, default=30000)
50
52
  p_tr.add_argument("--json", action="store_true")
53
+ p_tr.add_argument(
54
+ "--no-verify-identity",
55
+ action="store_true",
56
+ help="Отключить сверку идентичности спикеров с участниками записи (NFR-17, "
57
+ "по умолчанию включена)",
58
+ )
51
59
 
52
60
  p_sum = sub.add_parser("get-summary", help="Полное саммари записи (сеть)")
53
61
  p_sum.add_argument("recording_key")
@@ -111,18 +119,64 @@ def cmd_get_recording(_reg, args: argparse.Namespace) -> int:
111
119
  return _run(_get_recording(args), format_recording, json_flag=args.json)
112
120
 
113
121
 
122
+ async def _verify_transcript_identity(client: KTalkClient, recording_key: str, transcript: dict) -> dict:
123
+ """Оркестрация NFR-17 (ADR-023 §1, ред. 2a2f6e3): независимый вызов
124
+ `get_recording`; отказ вызова -> `not_checked` (маскирование той же функцией,
125
+ что и основная ошибка, NFR-5), не исключение наружу — основной результат
126
+ транскрипта возвращается в любом случае."""
127
+ try:
128
+ recording = await client.get_recording(recording_key)
129
+ except Exception as exc: # noqa: BLE001 - not_checked, не отказ команды
130
+ return {"result": "not_checked", "reason": redact_secrets(str(exc))}
131
+ return check_identity(transcript, recording)
132
+
133
+
134
+ def _render_transcript_with_identity(
135
+ output_text: str, identity_check: dict | None, *, json_flag: bool
136
+ ) -> str:
137
+ """Сборка вывода `get-transcript` (companion-спека «Оркестрация», шаг 4).
138
+ `identity_check is None` -> сверка отключена (`--no-verify-identity`), выводим
139
+ как раньше."""
140
+ if identity_check is None:
141
+ return output_text
142
+
143
+ if not json_flag:
144
+ line = f"[identity-check] {identity_check['result']}"
145
+ if "reason" in identity_check:
146
+ line += f" ({identity_check['reason']})"
147
+ return f"{output_text}\n{line}"
148
+
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
155
+
156
+ return json.dumps(
157
+ {"transcript": parsed, "identity_check": identity_check}, ensure_ascii=False, indent=2
158
+ )
159
+
160
+
114
161
  def cmd_get_transcript(_reg, args: argparse.Namespace) -> int:
115
- async def _fetch() -> dict:
162
+ async def _fetch() -> tuple[dict, dict | None]:
116
163
  async with KTalkClient.from_settings(Settings()) as client:
117
- return await client.get_transcript(args.recording_key)
164
+ data = await client.get_transcript(args.recording_key)
165
+ identity_check = None
166
+ 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
118
171
 
119
172
  try:
120
- data = asyncio.run(_fetch())
173
+ data, identity_check = asyncio.run(_fetch())
121
174
  except Exception as exc: # noqa: BLE001 - surface as CLI error, NFR-5 маскирует
122
175
  print(f"Ошибка: {redact_secrets(str(exc))}", file=sys.stderr)
123
176
  return 1
124
177
  fmt = "raw" if args.json else "markdown"
125
- print(render_transcript_output(data, fmt, args.chunk, args.chunk_size))
178
+ 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))
126
180
  return 0
127
181
 
128
182
 
@@ -123,7 +123,7 @@ def format_recording(data: dict) -> str:
123
123
  title = data.get("title", "Без названия")
124
124
  lines = [f"# {title}", ""]
125
125
 
126
- lines.append(f"- **Ключ:** {data.get('id', data.get('key', 'N/A'))}")
126
+ lines.append(f"- **Ключ:** {data.get('id') or data.get('key') or 'N/A'}")
127
127
  lines.append(f"- **Дата:** {_format_datetime(data.get('createdDate'))}")
128
128
 
129
129
  created_by = data.get("createdBy")
@@ -162,7 +162,7 @@ def format_recordings_list(data: dict) -> str:
162
162
  ]
163
163
 
164
164
  for rec in entities:
165
- rec_id = rec.get("id", rec.get("key", "N/A"))
165
+ rec_id = rec.get("id") or rec.get("key") or "N/A"
166
166
  title = rec.get("title", "Без названия")
167
167
  date = _format_datetime(rec.get("createdDate"))
168
168
  created_by = rec.get("createdBy") or {}
@@ -0,0 +1,69 @@
1
+ """Сверка идентичности транскрипта (NFR-17, issue #5, ADR-023 §1 + companion-спека
2
+ «Компоненты — NFR-17»): сверяет состав спикеров транскрипта с составом участников
3
+ записи того же `recording_id` через независимый вызов `get_recording` — обнаруживает
4
+ подмену транскрипта под конкуренцией (RES-006), не связанную с валидностью самого
5
+ запроса.
6
+
7
+ Чистые функции без сети и побочных эффектов — сеть (`client.get_recording`) и исход
8
+ `not_checked` (отказ вызова) живут в оркестрации `cmd_get_transcript`
9
+ (`cli_content.py`), не здесь.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+
15
+ def _identity_key(user_ref: dict | None) -> str | None:
16
+ """Идентификатор пользователя: `userInfo.key` или `.login` (именованный),
17
+ `anonymousId` или `anonymousName` (аноним), `None` если ссылка пуста — та же
18
+ приоритетность, что `_format_user_name(with_id=True)` в `formatters.py`, но
19
+ возвращает сырой токен для сравнения, не строку для вывода."""
20
+ if not user_ref:
21
+ return None
22
+ if user_ref.get("isAnonymous"):
23
+ return user_ref.get("anonymousId") or user_ref.get("anonymousName")
24
+ user_info = user_ref.get("userInfo")
25
+ if not user_info:
26
+ return None
27
+ return user_info.get("key") or user_info.get("login")
28
+
29
+
30
+ def speaker_identities(transcript: dict) -> set[str]:
31
+ """Состав спикеров транскрипта: обход `tracks[]`, `speaker`, при отсутствии —
32
+ `diarizedSpeaker`; треки без обоих не подмешивают `None` в результат."""
33
+ identities: set[str] = set()
34
+ for track in transcript.get("tracks") or []:
35
+ speaker_ref = track.get("speaker") or track.get("diarizedSpeaker")
36
+ key = _identity_key(speaker_ref)
37
+ if key is not None:
38
+ identities.add(key)
39
+ return identities
40
+
41
+
42
+ def participant_identities(recording: dict) -> set[str]:
43
+ """Состав участников записи: обход `recording["participants"]` (`TalkUserRef[]`)."""
44
+ identities: set[str] = set()
45
+ for participant in recording.get("participants") or []:
46
+ key = _identity_key(participant)
47
+ if key is not None:
48
+ identities.add(key)
49
+ return identities
50
+
51
+
52
+ def check_identity(transcript: dict, recording: dict) -> dict:
53
+ """Четыре исхода (`not_checked` не формируется здесь — исход уровня оркестрации,
54
+ когда сам вызов `get_recording` падает):
55
+ - `inconclusive` — любое множество пусто, сравнивать не с чем;
56
+ - `match` — пересечение непусто;
57
+ - `mismatch` — оба множества непусты и не пересекаются (сигнатура RES-006)."""
58
+ transcript_speakers = speaker_identities(transcript)
59
+ recording_participants = participant_identities(recording)
60
+
61
+ if not transcript_speakers or not recording_participants:
62
+ return {"result": "inconclusive"}
63
+
64
+ result = "match" if transcript_speakers & recording_participants else "mismatch"
65
+ return {
66
+ "result": result,
67
+ "transcript_speakers": sorted(transcript_speakers),
68
+ "recording_participants": sorted(recording_participants),
69
+ }
@@ -113,11 +113,15 @@ def test_get_recording_error_goes_to_stderr_with_nonzero_exit(httpx_mock: HTTPXM
113
113
 
114
114
  @pytest.mark.httpx_mock(assert_all_responses_were_requested=False)
115
115
  def test_get_transcript_json_default_returns_raw_json(httpx_mock: HTTPXMock, capsys):
116
+ """`--no-verify-identity`: предмет теста — форма raw-JSON транскрипта, не NFR-17
117
+ (ADR-023 §1 сделал сверку идентичности умолчанием — без флага здесь потребовался
118
+ бы и мок `get_recording`; отдельное покрытие умолчания-включено —
119
+ `tests/test_nfr17_identity_verification.py`)."""
116
120
  from ktalk_cli.cli import main
117
121
 
118
122
  httpx_mock.add_response(json={"status": "done", "tracks": []})
119
123
 
120
- rc = main(["get-transcript", "REC-1", "--json"])
124
+ rc = main(["get-transcript", "REC-1", "--json", "--no-verify-identity"])
121
125
 
122
126
  assert rc == 0
123
127
  out = json.loads(capsys.readouterr().out)
@@ -126,11 +130,13 @@ def test_get_transcript_json_default_returns_raw_json(httpx_mock: HTTPXMock, cap
126
130
 
127
131
  @pytest.mark.httpx_mock(assert_all_responses_were_requested=False)
128
132
  def test_get_transcript_markdown_default(httpx_mock: HTTPXMock, capsys):
133
+ """`--no-verify-identity` — см. комментарий у `test_get_transcript_json_default_
134
+ returns_raw_json` выше: предмет здесь — markdown-рендер, не NFR-17."""
129
135
  from ktalk_cli.cli import main
130
136
 
131
137
  httpx_mock.add_response(json={"status": "done", "tracks": []})
132
138
 
133
- rc = main(["get-transcript", "REC-1"])
139
+ rc = main(["get-transcript", "REC-1", "--no-verify-identity"])
134
140
 
135
141
  assert rc == 0
136
142
  assert "Транскрипт" in capsys.readouterr().out
@@ -0,0 +1,243 @@
1
+ """AT-design: FR-41 — наблюдаемость момента последней синхронизации реестра
2
+ (`content/40-architecture/at-design-adr023-open-issues.md`, раздел «FR-41»).
3
+
4
+ Покрывает 3 AC FR-41 из `content/30-requirements/registry-sync-observability.md`
5
+ (BA-014) через `#### Scenario:` капабилити-спеки `openspec/specs/registry-sync-window/
6
+ spec.md`, раздел «The last sync moment is exposed by a reading command, not only
7
+ recorded internally»:
8
+ - FR41-AC1 — `dashboard --json` после `sync` отдаёт то же значение, что записал `sync`.
9
+ - FR41-AC2 — свежий реестр без единого `sync` -> ключ `last_synced` присутствует,
10
+ значение явный `null`, не пропуск поля.
11
+ - FR41-AC3 — серия чтений `dashboard --json` не меняет статусы записей реестра.
12
+
13
+ Решение SA (ADR-023 §2): `last_synced` — поле ВЕРХНЕГО уровня JSON-ответа `dashboard
14
+ --json` (не внутри `stats` — тот однородный `{статус: count}`), значение
15
+ `Registry.get_meta("last_synced")` как есть, формат ISO-8601 `YYYY-MM-DD` (точность
16
+ до дня, `cli_sync.py:126` не меняется).
17
+
18
+ Красные по замыслу: `_cmd_dashboard` (`cli.py:210`) сегодня печатает только `{"new":
19
+ new, "stats": stats}` — поля `last_synced` в ответе нет вовсе. Стабы падают на
20
+ `assert`/`KeyError` через `out["last_synced"]`, не на импорте — модуль и функция уже
21
+ существуют, меняется состав их JSON-ответа. Реализация — DEV-019 (после этого файла).
22
+
23
+ Классы обязательного покрытия (контракт QA-author):
24
+ - **Испорченный/опечатанный ввод** — N/A для FR-41: `dashboard --json` не принимает от
25
+ пользователя ни идентификатор записи, ни сегмент URL, ни поисковый запрос — команда
26
+ без позиционных аргументов, читает весь реестр целиком. Испорченный `--db` путь —
27
+ предмет других файлов (не FR-41 конкретно), не дублируется здесь.
28
+ - **Замаскированный отказ** — FR41-AC2 и есть этот класс дословно: `null` должен быть
29
+ ЯВНЫМ значением ключа, не тихим пропуском поля (что было бы неотличимо от дефекта
30
+ сериализации) и не тихой заменой на другое значение по умолчанию. Отдельный
31
+ усиленный тест ниже проверяет это конкретно на уровне текста ответа, не только на
32
+ уровне распарсенного словаря.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import json
38
+ from datetime import date, timedelta
39
+ from pathlib import Path
40
+
41
+
42
+ def _seed_mixed_statuses(db: Path) -> None:
43
+ from ktalk_cli.registry import Registry
44
+
45
+ today = date.today().isoformat()
46
+ with Registry(db) as reg:
47
+ reg.upsert_recording(
48
+ {"recording_id": "a", "name": "Standup", "date": today}, now=today
49
+ )
50
+ reg.upsert_recording(
51
+ {"recording_id": "b", "name": "1-1", "date": today}, now=today
52
+ )
53
+ reg.set_status("b", "done")
54
+ reg.upsert_recording(
55
+ {"recording_id": "c", "name": "Retro", "date": today}, now=today
56
+ )
57
+ reg.set_status("c", "partial")
58
+ reg.upsert_recording(
59
+ {"recording_id": "d", "name": "Old", "date": today}, now=today
60
+ )
61
+ reg.set_status("d", "skipped")
62
+
63
+
64
+ def _run_sync(db: Path, monkeypatch, httpx_mock, capsys) -> None:
65
+ """Реальный `ktalk sync` через CLI (не прямой `reg.set_meta`) — FR41-AC1 сверяет
66
+ ответ `dashboard --json` со значением, которое сама команда `sync` записала,
67
+ воспроизводя путь AC дословно: "сравнение значения из --json-ответа со значением
68
+ Registry.get_meta("last_synced") после sync". Явно вычищает буфер `capsys` после
69
+ себя — иначе собственный JSON-вывод `sync` конкатенируется с последующим выводом
70
+ `dashboard --json` в одном захвате и ломает `json.loads` `Extra data`-ошибкой,
71
+ маскируя реальную причину падения теста своей собственной (control-flow
72
+ ошибка теста, не отсутствующее поведение под тестом)."""
73
+ monkeypatch.setenv("KTALK_BASE_URL", "https://test.ktalk.ru")
74
+ monkeypatch.setenv("KTALK_SESSION_TOKEN", "tok-fr41")
75
+ monkeypatch.delenv("KTALK_PERSONAL_API_KEY", raising=False)
76
+ monkeypatch.delenv("KTALK_REGISTRY_DB", raising=False)
77
+
78
+ httpx_mock.add_response(json={"recordings": []}) # одна пустая страница -> sync done
79
+
80
+ from ktalk_cli.cli import main
81
+
82
+ rc = main(["--db", str(db), "sync", "--days", "7", "--json"])
83
+ assert rc == 0, "предпосылка теста: sync должен пройти успешно"
84
+ capsys.readouterr() # вычищает собственный вывод sync перед вызовами dashboard
85
+
86
+
87
+ # === FR41-AC1: значение после sync совпадает с записанным ==================================
88
+
89
+
90
+ def test_fr41_ac1_dashboard_json_last_synced_matches_registry_get_meta_after_sync(
91
+ tmp_path, monkeypatch, httpx_mock, capsys
92
+ ):
93
+ from ktalk_cli.cli import main
94
+ from ktalk_cli.registry import Registry
95
+
96
+ db = tmp_path / "r.db"
97
+ _run_sync(db, monkeypatch, httpx_mock, capsys)
98
+
99
+ with Registry(db) as reg:
100
+ recorded = reg.get_meta("last_synced")
101
+ assert recorded is not None, "предпосылка: sync должен был записать last_synced"
102
+
103
+ rc = main(["--db", str(db), "dashboard", "--json"])
104
+ out = json.loads(capsys.readouterr().out)
105
+
106
+ assert rc == 0
107
+ assert "last_synced" in out, (
108
+ "FR41-AC1: `dashboard --json` обязан отдавать `last_synced` верхнего уровня "
109
+ f"после хотя бы одной синхронизации, получен ответ без ключа: {out}"
110
+ )
111
+ assert out["last_synced"] == recorded, (
112
+ "FR41-AC1: значение в ответе должно совпадать со значением, которое записала "
113
+ f"сама sync — записано {recorded!r}, в ответе {out.get('last_synced')!r}"
114
+ )
115
+
116
+
117
+ def test_fr41_ac1_last_synced_is_top_level_not_nested_in_stats(
118
+ tmp_path, monkeypatch, httpx_mock, capsys
119
+ ):
120
+ """Решение SA (ADR-023 §2): поле верхнего уровня, НЕ внутри `stats` (тот
121
+ однородный `{статус: count}` — чужеродное поле ломает эту форму для потребителя,
122
+ итерирующего по нему)."""
123
+ from ktalk_cli.cli import main
124
+
125
+ db = tmp_path / "r.db"
126
+ _run_sync(db, monkeypatch, httpx_mock, capsys)
127
+
128
+ rc = main(["--db", str(db), "dashboard", "--json"])
129
+ out = json.loads(capsys.readouterr().out)
130
+
131
+ assert rc == 0
132
+ assert "last_synced" not in out.get("stats", {}), (
133
+ "FR41-AC1 (форма ответа, ADR-023 §2): `last_synced` не должен жить внутри "
134
+ f"`stats` — обнаружен там: {out.get('stats')}"
135
+ )
136
+
137
+
138
+ # === FR41-AC2: свежий реестр без sync -> явный null, не пропуск ключа ======================
139
+
140
+
141
+ def test_fr41_ac2_never_synced_registry_reports_explicit_null_not_missing_key(
142
+ tmp_path, capsys
143
+ ):
144
+ from ktalk_cli.cli import main
145
+
146
+ db = tmp_path / "r.db" # реестр создаётся с нуля, sync ни разу не вызывался
147
+
148
+ rc = main(["--db", str(db), "dashboard", "--json"])
149
+ out = json.loads(capsys.readouterr().out)
150
+
151
+ assert rc == 0
152
+ assert "last_synced" in out, (
153
+ "FR41-AC2: ключ `last_synced` обязан присутствовать в ответе даже без единой "
154
+ f"синхронизации — тихий пропуск поля запрещён требованием, получен ответ: {out}"
155
+ )
156
+ assert out["last_synced"] is None, (
157
+ "FR41-AC2: без единой синхронизации значение обязано быть явным `null`, "
158
+ f"получено {out['last_synced']!r}"
159
+ )
160
+
161
+
162
+ def test_fr41_ac2_masked_failure_null_is_literal_json_null_not_omitted_field(
163
+ tmp_path, capsys
164
+ ):
165
+ """Класс «замаскированный отказ»: наивная реализация могла бы тихо ОПУСТИТЬ ключ,
166
+ когда `get_meta` возвращает `None` (обычный паттерн `if value: payload[...] = ...`)
167
+ — снаружи это неотличимо от дефекта сериализации, тот самый риск, который AC2
168
+ называет прямым текстом. Проверяем на уровне сырого текста stdout, не только
169
+ распарсенного словаря — исключает случай, где парсер молча подставляет `None` за
170
+ отсутствующий ключ и маскирует разницу между "ключа нет" и "ключ есть, там null"."""
171
+ from ktalk_cli.cli import main
172
+
173
+ db = tmp_path / "r.db"
174
+
175
+ rc = main(["--db", str(db), "dashboard", "--json"])
176
+ raw = capsys.readouterr().out
177
+
178
+ assert rc == 0
179
+ assert '"last_synced"' in raw, (
180
+ "FR41-AC2 (замаскированный отказ): литерал ключа `\"last_synced\"` обязан "
181
+ f"присутствовать в сыром JSON-тексте ответа, получено: {raw!r}"
182
+ )
183
+
184
+
185
+ # === FR41-AC3: серия чтений не мутирует статусы записей =====================================
186
+
187
+
188
+ def test_fr41_ac3_repeated_dashboard_json_calls_do_not_change_any_recording_status(
189
+ tmp_path, monkeypatch, httpx_mock, capsys
190
+ ):
191
+ from ktalk_cli.cli import main
192
+ from ktalk_cli.registry import Registry
193
+
194
+ db = tmp_path / "r.db"
195
+ _run_sync(db, monkeypatch, httpx_mock, capsys) # last_synced присутствует, не только пустой реестр
196
+ _seed_mixed_statuses(db)
197
+
198
+ with Registry(db) as reg:
199
+ before = {r["recording_id"]: r["status"] for r in reg.list_recordings()}
200
+
201
+ for _ in range(5):
202
+ rc = main(["--db", str(db), "dashboard", "--json"])
203
+ capsys.readouterr()
204
+ assert rc == 0
205
+
206
+ with Registry(db) as reg:
207
+ after = {r["recording_id"]: r["status"] for r in reg.list_recordings()}
208
+
209
+ assert after == before, (
210
+ "FR41-AC3: пятикратный вызов `dashboard --json` (в т.ч. читающего "
211
+ f"`last_synced`) не должен менять статусы записей — было {before}, стало {after}"
212
+ )
213
+
214
+
215
+ def test_fr41_ac3_repeated_dashboard_json_calls_do_not_change_sync_meta(
216
+ tmp_path, monkeypatch, httpx_mock, capsys
217
+ ):
218
+ """Узкая версия AC3: сам `last_synced`/`sync_count` в `meta` — не только статусы
219
+ записей — не должны сдвигаться от чтения (не только не регрессировать в другой
220
+ статус, но и не «обновляться» будто чтение — это ползучий sync)."""
221
+ from ktalk_cli.cli import main
222
+ from ktalk_cli.registry import Registry
223
+
224
+ db = tmp_path / "r.db"
225
+ _run_sync(db, monkeypatch, httpx_mock, capsys)
226
+
227
+ with Registry(db) as reg:
228
+ synced_before = reg.get_meta("last_synced")
229
+ count_before = reg.get_meta("sync_count")
230
+
231
+ for _ in range(3):
232
+ main(["--db", str(db), "dashboard", "--json"])
233
+ capsys.readouterr()
234
+
235
+ with Registry(db) as reg:
236
+ synced_after = reg.get_meta("last_synced")
237
+ count_after = reg.get_meta("sync_count")
238
+
239
+ assert synced_after == synced_before
240
+ assert count_after == count_before, (
241
+ "FR41-AC3: чтение `dashboard --json` не должно инкрементировать `sync_count` "
242
+ f"— было {count_before}, стало {count_after}"
243
+ )