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.
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/PKG-INFO +1 -1
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/pyproject.toml +1 -1
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/cli.py +1 -1
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/cli_content.py +58 -4
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/formatters.py +2 -2
- ktalk_cli-2.0.0/src/ktalk_cli/transcript_identity.py +69 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_cli_content.py +8 -2
- ktalk_cli-2.0.0/tests/test_fr41_last_synced.py +243 -0
- ktalk_cli-2.0.0/tests/test_issue3_null_id_fallback.py +177 -0
- ktalk_cli-2.0.0/tests/test_nfr17_identity_verification.py +465 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/.gitignore +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/LICENSE +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/README.md +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/CLAUDE.md +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/__init__.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/auth.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/calendar_reader.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/cli_contacts.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/cli_meeting.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/cli_meeting_args.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/cli_meeting_confirm.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/cli_meetings_read.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/cli_sanction.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/cli_store.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/cli_sync.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/cli_token.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/client.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/config.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/confirmation.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/contacts.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/contour_diagnostics.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/download.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/endpoints.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/enrichment.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/host_config.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/meeting_body.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/meeting_cancel.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/meeting_scheduling.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/pagination.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/reconciliation.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/registry.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/rooms.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/store.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/store_migration.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/token_file.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/write_journal.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/src/ktalk_cli/write_sanction.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/conftest.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/access-info-expired.json +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/access-info-full.json +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/archive-8col.md +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/archive-dup-id.md +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/archive-escaped-pipe-7col.md +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/archive-page1.json +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/archive-page2-empty.json +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/archive-pipe-in-name.md +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/calendar-item-session.json +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/chat-messages-session.json +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/conference-history-session-oversized.json +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/conference-history-session.json +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/error-body-problem-json.json +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/error-body-validation.json +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/recording-detail-session-full-participants.json +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/recording-detail-session-oversized-partial.json +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/recording-detail-with-qualities.json +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/recording-list-item-session.json +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/recordings-list-apikey-page1.json +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/recordings-list-apikey-page2.json +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/registry-archive-2026-04.md +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/registry.md +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/fixtures/room-detail-session.json +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_adr022_ktalk_cli_rename.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_archive.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_auth_modes.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_calendar.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_chat.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_cli.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_cli_config_show.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_cli_export_store_isolation.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_cli_host_config_wiring.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_cli_meeting.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_cli_meeting_sanctioned.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_cli_meetings_surface.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_cli_migrate_to_central_store.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_client.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_concurrency.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_config.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_confirmation.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_contour_diagnostics.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_diagnostics.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_download.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_enrichment.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_formatters.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_fr19_auth_status.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_fr21_no_vault_layout.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_fr39_calendar_inclusive_end.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_fr40_timezone_format.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_host_config.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_meeting_body.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_meeting_cancel.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_meeting_scheduling.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_migration.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_pagination.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_reconciliation.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_registry.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_rooms.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_search_contacts.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_secret_masking.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_store.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_store_migration.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_token_file.py +0 -0
- {ktalk_cli-1.0.0 → ktalk_cli-2.0.0}/tests/test_write_journal.py +0 -0
- {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:
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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'
|
|
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"
|
|
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
|
+
)
|