tytcli 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,898 @@
1
+ """``tytcli roadshow`` — 路演中心:查询、预约、我的预约、AI 纪要.
2
+
3
+ 与公众号、情绪指数两个模块同一个 API 根(``MIDDLE_BASE_URLS`` +
4
+ ``/RoadShowCenter/``);线路层的契约记在 ``client.py`` 的模块 docstring 里,
5
+ 下面这些都是**命令层**的决定。
6
+
7
+ 写操作默认只做只读预检
8
+ ----------------------
9
+
10
+ ``book`` 与 ``cancel`` 会改变服务端状态,而与另外两个 middle 模块不同,
11
+ **这个模块在 prod 上是部署着的**(prod 调用回答 ``-100``,那就是路由存在的证明)。
12
+ 所以一次误调用就会留下一条真实预约。因此这两个命令都先调
13
+ ``getRoadShowBookingStatus``,并且只在给了 ``--yes`` 时才 POST。不带 ``--yes``
14
+ 时它们只报告"**将会**发生什么",什么都不碰。
15
+
16
+ 预检同时让这两个操作在 CLI 层面也是幂等的(叠加在服务端自身的幂等之上;已实测:
17
+ 重复预约会成功且 ``bookTime`` 不变,重复取消会成功):
18
+
19
+ * 已经预约 -> ``already_booked: true``,不发 POST
20
+ * ``canBook`` 为假 -> ``ROADSHOW_NOT_BOOKABLE``(exit 1),带上服务端自己的
21
+ ``reason``,不发 POST
22
+ * POST 成功之后 -> 再读一次状态并作为 ``confirmed`` 汇报,与 ``auth --verify``
23
+ 用的是同一套"写后回读"纪律
24
+ * POST 超时 -> 再读一次状态,让**它**来决定结果,因为交接文档自己的建议就是
25
+ "查询本人预约状态,再决定界面结果"。超时不是失败的证据;在预约其实已经存在的
26
+ 情况下报一个失败,正是 agent 最终造成重复预约的方式。
27
+
28
+ 日期处理
29
+ --------
30
+
31
+ 两个列表接口都按日期筛选,但**格式不同、边界语义相反**:列表要 ``yyyy/M/d``,
32
+ 且它的 ``endDate`` 截止在该日 00:00:00(所以不含当天的会议);而我的预约要
33
+ ``yyyy-MM-dd``,且它的 ``meetingEndDate`` 含当天全天。
34
+
35
+ CLI 对两者都接受 ISO 的 ``YYYY-MM-DD`` 并在内部转译,因为让调用方记住两种格式
36
+ 正是产生"静默给出错误答案"的方式。转译不是装饰:探测发现传给列表接口的短横线
37
+ 日期会被**静默忽略**(筛选根本不生效,返回的是完整结果集),而且观测到的行为在
38
+ 负载均衡后面的各个 test 实例之间并不一致。文档规定的斜杠格式在每一次观测中都正确
39
+ 生效,所以上网的就是它。
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import re
45
+ from collections.abc import Sequence
46
+ from datetime import datetime
47
+ from typing import Any, cast
48
+
49
+ import click
50
+
51
+ from ..client import ROADSHOW_BOOKING_STATES, ROADSHOW_THEMES, TytClient
52
+ from ..config import ResolvedConfig
53
+ from ..errors import ApiError, NetworkError, UsageError
54
+ from ..output import HINT_KEY, emit_ok
55
+ from .shared import (
56
+ middle_client,
57
+ observed_fields,
58
+ parse_fields_option,
59
+ raise_for_envelope,
60
+ require_config,
61
+ select_fields,
62
+ trim_page,
63
+ unwrap_data,
64
+ )
65
+
66
+ #: 搜索结果行的默认字段:够用来挑出一场会议,并刻意排除那些又大又敏感的字段。
67
+ #: ``summary`` 是列表自带的简略纪要,属于长文本——完整的 AI 纪要有自己的命令。
68
+ #: ``meetingUrl`` / ``meetingPwd`` 是会议链接和它的**密码**,绝不该出现在会被记进
69
+ #: 日志和对话的默认输出里;``--full`` 仍然原样返回它们。``pictureUrl`` 是加密的
70
+ #: 封面地址,命令行渲染不了。
71
+ ROADSHOW_DEFAULT_FIELDS: tuple[str, ...] = (
72
+ "appId",
73
+ "title",
74
+ "roadShowTime",
75
+ "publishState",
76
+ "institutions",
77
+ "speakers",
78
+ "theme",
79
+ )
80
+
81
+ #: 预约行的默认字段。``bookingId`` 与 ``theme`` 不在默认集里(取消预约用的是
82
+ #: ``appId``,不是 ``bookingId``),但可以用 ``--fields`` 要回来。
83
+ BOOKINGS_DEFAULT_FIELDS: tuple[str, ...] = (
84
+ "appId",
85
+ "title",
86
+ "roadShowTime",
87
+ "publishState",
88
+ "institutions",
89
+ "speakers",
90
+ "bookingViewState",
91
+ "bookTime",
92
+ "isAvailable",
93
+ "unavailableReason",
94
+ )
95
+
96
+ #: AI 纪要的"简略版"视图。``--full`` 返回服务端发来的每一个字段,
97
+ #: 包括 ``detail`` 与十个专题段落。
98
+ SUMMARY_BRIEF_FIELDS: tuple[str, ...] = ("id", "meetingId", "type", "summary")
99
+
100
+ #: "有这些字段才算有内容可看"。用于决定 ``available``:该接口只返回审核通过的
101
+ #: 非草稿记录,所以"还没生成""待审核""草稿""已删除"四种情况无法区分,
102
+ #: 一个空的 ``detail`` 绝不能被读成"纪要内容是空的"。
103
+ SUMMARY_CONTENT_FIELDS: tuple[str, ...] = (
104
+ "summary",
105
+ "detail",
106
+ "timeline",
107
+ "companyOverview",
108
+ "productStrategy",
109
+ "fundPerformance",
110
+ "marketOutlook",
111
+ "riskManagement",
112
+ "teamDivision",
113
+ "investHighlights",
114
+ "mainRisks",
115
+ "qaPoints",
116
+ )
117
+
118
+ ISO_DATE_RE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})$")
119
+ DATETIME_RE = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$")
120
+
121
+ #: publishState 的编码表,用于在原始值旁边附一个人能读懂的标签。
122
+ PUBLISH_STATES: dict[str, str] = {
123
+ "0": "未发布",
124
+ "1": "回放",
125
+ "2": "预告",
126
+ "3": "直播中",
127
+ "4": "已结束(未传回放)",
128
+ "5": "已结束(已传回放)",
129
+ }
130
+
131
+
132
+ # --------------------------------------------------------------------------- #
133
+ # 校验与转译
134
+ # --------------------------------------------------------------------------- #
135
+
136
+
137
+ def _validate_page(page: int, page_size: int) -> None:
138
+ """照搬服务端自己的规则:必须是正整数。
139
+
140
+ 在客户端就校验,这样失败是 exit 2 的 ``USAGE_*``、且 ``details`` 里带着那个
141
+ 非法值;而不是 exit 1 加一句需要 agent 去模式匹配的中文。服务端的报文是
142
+ ``页码和每页条数必须为正整数``;它没有规定上界,所以这里也不设上界。
143
+ """
144
+ if page < 1:
145
+ raise UsageError(
146
+ f"--page 必须 >= 1(当前 {page})。",
147
+ code="USAGE_PAGE_OUT_OF_RANGE",
148
+ details={"page": page, "min": 1},
149
+ )
150
+ if page_size < 1:
151
+ raise UsageError(
152
+ f"--page-size 必须 >= 1(当前 {page_size})。",
153
+ code="USAGE_PAGE_SIZE_OUT_OF_RANGE",
154
+ details={"page_size": page_size, "min": 1},
155
+ )
156
+
157
+
158
+ def _validate_iso_date(raw: str, option: str) -> str:
159
+ if not ISO_DATE_RE.match(raw):
160
+ raise UsageError(
161
+ f"{option} 必须是 YYYY-MM-DD 格式(当前 {raw!r})。",
162
+ code="USAGE_DATE_INVALID",
163
+ details={"option": option, "value": raw, "expected_format": "YYYY-MM-DD"},
164
+ )
165
+ try:
166
+ datetime.strptime(raw, "%Y-%m-%d")
167
+ except ValueError as exc:
168
+ raise UsageError(
169
+ f"{option} 不是真实存在的日期(当前 {raw!r})。",
170
+ code="USAGE_DATE_INVALID",
171
+ details={"option": option, "value": raw, "reason": str(exc)},
172
+ ) from exc
173
+ return raw
174
+
175
+
176
+ def _validate_datetime(raw: str, option: str) -> str:
177
+ if not DATETIME_RE.match(raw):
178
+ raise UsageError(
179
+ f"{option} 必须是 'YYYY-MM-DD HH:MM:SS' 格式(当前 {raw!r})。",
180
+ code="USAGE_DATETIME_INVALID",
181
+ details={
182
+ "option": option,
183
+ "value": raw,
184
+ "expected_format": "YYYY-MM-DD HH:MM:SS",
185
+ },
186
+ )
187
+ try:
188
+ datetime.strptime(raw, "%Y-%m-%d %H:%M:%S")
189
+ except ValueError as exc:
190
+ raise UsageError(
191
+ f"{option} 不是真实存在的日期时间(当前 {raw!r})。",
192
+ code="USAGE_DATETIME_INVALID",
193
+ details={"option": option, "value": raw, "reason": str(exc)},
194
+ ) from exc
195
+ return raw
196
+
197
+
198
+ def _validate_range(
199
+ start: str | None,
200
+ end: str | None,
201
+ start_option: str,
202
+ end_option: str,
203
+ ) -> None:
204
+ """在客户端就拒绝反向区间;服务端的报文只是一句话,无法分支。"""
205
+ if start is not None and end is not None and start > end:
206
+ raise UsageError(
207
+ f"{start_option} 不能晚于 {end_option}({start} > {end})。",
208
+ code="USAGE_RANGE_REVERSED",
209
+ details={
210
+ "start_option": start_option,
211
+ "end_option": end_option,
212
+ "start": start,
213
+ "end": end,
214
+ },
215
+ )
216
+
217
+
218
+ def _to_listing_date(iso: str) -> str:
219
+ """``2026-11-01`` -> ``2026/11/1``,也就是列表接口真正会用来筛选的格式。
220
+
221
+ 已实测:短横线格式的日期会被 ``getRoadShowInfo`` **静默忽略**(返回未过滤的
222
+ 总数),而且这个行为在负载均衡后面的各个 test 实例之间甚至不一致。斜杠格式
223
+ 在每一次观测中都生效。去掉前导零是为了与文档规定的 ``yyyy/M/d`` 完全一致。
224
+ """
225
+ match = ISO_DATE_RE.match(iso)
226
+ if match is None: # pragma: no cover - _validate_iso_date 会先跑
227
+ raise UsageError(
228
+ f"内部错误:{iso!r} 不是 YYYY-MM-DD。",
229
+ code="USAGE_DATE_INVALID",
230
+ details={"value": iso},
231
+ )
232
+ year, month, day = match.groups()
233
+ return f"{year}/{int(month)}/{int(day)}"
234
+
235
+
236
+ def _normalize_theme(raw: str) -> str | None:
237
+ """接受编码或中文行业名;返回编码,"全部"时返回 None。
238
+
239
+ ``"0"``、``""`` 与不传,在服务端都表示"不筛选"(按行数实测确认),所以这三种
240
+ 在这里一律收敛成 ``None``,并且**省略该键**而不是发一个空值过去。
241
+ """
242
+ value = raw.strip()
243
+ if not value or value == "0":
244
+ return None
245
+ if value in ROADSHOW_THEMES:
246
+ return value
247
+ for code, label in ROADSHOW_THEMES.items():
248
+ if label == value:
249
+ return code
250
+ raise UsageError(
251
+ f"--theme 必须是 {'/'.join(ROADSHOW_THEMES)} 之一,或对应的行业名"
252
+ f"({'、'.join(ROADSHOW_THEMES.values())});当前 {raw!r}。",
253
+ code="USAGE_THEME_INVALID",
254
+ details={"value": raw, "valid": dict(ROADSHOW_THEMES)},
255
+ )
256
+
257
+
258
+ def _validate_app_id(raw: str) -> str:
259
+ """去掉 argv 带来的空白,然后要求确实还剩下内容。
260
+
261
+ 值**周围**的空白是 shell 造成的,去掉是安全的。而加密 id 本身含有
262
+ ``+`` / ``/`` / ``=``,必须逐字节原样传递——格式不对时服务端回
263
+ ``路演ID格式不正确``,缺失时回 ``请选择路演后重试``。
264
+ """
265
+ app_id = raw.strip()
266
+ if not app_id:
267
+ raise UsageError(
268
+ "--app-id 不能为空。它来自 roadshow search / bookings 输出的 appId。",
269
+ code="USAGE_APP_ID_EMPTY",
270
+ details={},
271
+ )
272
+ return app_id
273
+
274
+
275
+ def _validate_state(raw: str) -> str:
276
+ state = raw.strip()
277
+ if state not in ROADSHOW_BOOKING_STATES:
278
+ raise UsageError(
279
+ f"--state 只能是 {' 或 '.join(ROADSHOW_BOOKING_STATES)}(当前 {raw!r})。"
280
+ "不传表示查询全部未取消的预约。",
281
+ code="USAGE_STATE_INVALID",
282
+ details={"value": raw, "valid": list(ROADSHOW_BOOKING_STATES)},
283
+ )
284
+ return state
285
+
286
+
287
+ # --------------------------------------------------------------------------- #
288
+ # shared plumbing
289
+ # --------------------------------------------------------------------------- #
290
+
291
+
292
+ def _client(config: ResolvedConfig) -> TytClient:
293
+ return middle_client(config)
294
+
295
+
296
+ def _raise(envelope: Any, config: ResolvedConfig) -> None:
297
+ raise_for_envelope(
298
+ envelope, env_name=config.active_env, base_url=config.middle_base_url
299
+ )
300
+
301
+
302
+ def _data_map(envelope: Any) -> dict[str, Any] | None:
303
+ """把 ``Data`` 当作映射返回;缺失或不是对象时返回 None。
304
+
305
+ 在这里"缺失"是一种真实情况,不是错误:没有可公开的纪要时,纪要接口会整个
306
+ 省略 ``Data``;预约被取消后,状态接口会省略 ``bookTime``。
307
+ """
308
+ raw = unwrap_data(envelope.payload)
309
+ return cast("dict[str, Any]", raw) if isinstance(raw, dict) else None
310
+
311
+
312
+ def _status(client: TytClient, config: ResolvedConfig, app_id: str) -> dict[str, Any]:
313
+ """读取调用方对某一场路演的预约状态。"""
314
+ envelope = client.roadshow_booking_status(app_id=app_id)
315
+ _raise(envelope, config)
316
+ return _data_map(envelope) or {}
317
+
318
+
319
+ def _search_hint(default_fields: Sequence[str], observed: list[str]) -> str:
320
+ extra = [field for field in observed if field not in default_fields]
321
+ parts = [f"默认每场路演仅输出:{', '.join(default_fields)}。"]
322
+ if extra:
323
+ parts.append(f"该接口本次还返回了:{', '.join(extra)}。")
324
+ parts.append(
325
+ "用 --fields a,b,c 自选字段,或 --full 取服务端原样全量"
326
+ "(含 summary 简略纪要与 meetingUrl/meetingPwd 会议链接及密码)。"
327
+ )
328
+ parts.append("appId 可直接用于:tytcli roadshow status|summary|book --app-id <ID>。")
329
+ return "".join(parts)
330
+
331
+
332
+ def _bookings_hint(default_fields: Sequence[str], observed: list[str]) -> str:
333
+ extra = [field for field in observed if field not in default_fields]
334
+ parts = [f"默认每条预约仅输出:{', '.join(default_fields)}。"]
335
+ if extra:
336
+ parts.append(f"该接口本次还返回了:{', '.join(extra)}。")
337
+ parts.append("用 --fields a,b,c 自选字段,或 --full 取服务端原样全量。")
338
+ parts.append("取消预约用 appId(不是 bookingId):tytcli roadshow cancel --app-id <ID> --yes。")
339
+ return "".join(parts)
340
+
341
+
342
+ # --------------------------------------------------------------------------- #
343
+ # 命令组
344
+ # --------------------------------------------------------------------------- #
345
+
346
+
347
+ @click.group()
348
+ def roadshow() -> None:
349
+ """路演中心:查询、预约、我的预约与 AI 纪要。"""
350
+
351
+
352
+ def _fields_options(func: Any) -> Any:
353
+ """``--fields`` / ``--full`` 这一对选项,三个需要裁剪的命令共用。"""
354
+ func = click.option(
355
+ "--full",
356
+ is_flag=True,
357
+ help="输出服务端原样全量字段(与 --fields 互斥)。",
358
+ )(func)
359
+ return click.option(
360
+ "--fields",
361
+ "fields_option",
362
+ metavar="A,B,C",
363
+ help="只输出这些字段,逗号分隔(与 --full 互斥)。",
364
+ )(func)
365
+
366
+
367
+ def _resolve_fields(fields_option: str | None, full: bool) -> list[str] | None:
368
+ if full and fields_option is not None:
369
+ raise UsageError(
370
+ "--full 与 --fields 互斥:前者要全量,后者要自选。",
371
+ code="USAGE_FIELDS_CONFLICT",
372
+ )
373
+ return parse_fields_option(fields_option) if fields_option is not None else None
374
+
375
+
376
+ @roadshow.command("search")
377
+ @click.option("--title", help="标题模糊查询。")
378
+ @click.option("--institution", help="机构模糊查询。")
379
+ @click.option("--speaker", help="主讲人模糊查询(多位主讲人任一匹配)。")
380
+ @click.option(
381
+ "--theme",
382
+ metavar="CODE|NAME",
383
+ help="行业筛选:1权益 2固收+ 3纯债 4互认 5策略 6热点;可用编码或行业名,不传为全部。",
384
+ )
385
+ @click.option("--start", "start_date", metavar="YYYY-MM-DD", help="会议开始日期下限。")
386
+ @click.option(
387
+ "--end",
388
+ "end_date",
389
+ metavar="YYYY-MM-DD",
390
+ help="会议开始日期上限。注意:服务端截止该日 00:00:00,**不含**当天白天的会议。",
391
+ )
392
+ @click.option("--page", "page_num", type=int, default=1, show_default=True, help="页码,从 1 开始。")
393
+ @click.option(
394
+ "--page-size", "page_size", type=int, default=20, show_default=True, help="每页条数。"
395
+ )
396
+ @_fields_options
397
+ @click.pass_context
398
+ def roadshow_search(
399
+ ctx: click.Context,
400
+ title: str | None,
401
+ institution: str | None,
402
+ speaker: str | None,
403
+ theme: str | None,
404
+ start_date: str | None,
405
+ end_date: str | None,
406
+ page_num: int,
407
+ page_size: int,
408
+ fields_option: str | None,
409
+ full: bool,
410
+ ) -> None:
411
+ """按时间、行业、机构、主讲人多条件查询路演。
412
+
413
+ \b
414
+ 默认输出是精简索引(appId/title/roadShowTime/publishState/institutions/
415
+ speakers/theme),并在 __tytcli_hint__ 里说明还能取到哪些字段。
416
+
417
+ \b
418
+ 列表**不含**预约状态:服务端没有批量查询预约资格的接口。
419
+ 要看某一场能否预约,用 appId 调:
420
+ tytcli roadshow status --app-id <ID>
421
+ """
422
+ selected = _resolve_fields(fields_option, full)
423
+ _validate_page(page_num, page_size)
424
+ theme_code = _normalize_theme(theme) if theme is not None else None
425
+ if start_date is not None:
426
+ _validate_iso_date(start_date, "--start")
427
+ if end_date is not None:
428
+ _validate_iso_date(end_date, "--end")
429
+ _validate_range(start_date, end_date, "--start", "--end")
430
+
431
+ config = require_config()
432
+ client = _client(config)
433
+ envelope = client.roadshow_search(
434
+ page_num=page_num,
435
+ page_size=page_size,
436
+ title=title,
437
+ institution=institution,
438
+ speaker=speaker,
439
+ theme=theme_code,
440
+ start_date=_to_listing_date(start_date) if start_date else None,
441
+ end_date=_to_listing_date(end_date) if end_date else None,
442
+ )
443
+ _raise(envelope, config)
444
+
445
+ inner_raw = unwrap_data(envelope.payload)
446
+ meta: dict[str, Any] = {
447
+ "env": config.active_env,
448
+ "base_url": config.middle_base_url,
449
+ "api_root": "middleApi",
450
+ "http_status": envelope.http_status,
451
+ "page": page_num,
452
+ "page_size": page_size,
453
+ }
454
+ # 回显实际生效的筛选条件,包括转译后的日期格式:一个被服务端**静默忽略**
455
+ # 的日期筛选,与一个"筛选生效但没有匹配"的结果,在输出上本来无法区分。
456
+ applied: dict[str, Any] = {}
457
+ if title is not None:
458
+ applied["title"] = title
459
+ if institution is not None:
460
+ applied["institution"] = institution
461
+ if speaker is not None:
462
+ applied["speaker"] = speaker
463
+ if theme_code is not None:
464
+ applied["theme"] = theme_code
465
+ if start_date is not None:
466
+ applied["start"] = start_date
467
+ applied["start_sent_as"] = _to_listing_date(start_date)
468
+ if end_date is not None:
469
+ applied["end"] = end_date
470
+ applied["end_sent_as"] = _to_listing_date(end_date)
471
+ applied["end_includes_that_day"] = False
472
+ if applied:
473
+ meta["filters"] = applied
474
+
475
+ page = trim_page(
476
+ inner_raw=inner_raw,
477
+ list_key="infos",
478
+ default_fields=ROADSHOW_DEFAULT_FIELDS,
479
+ selected=selected,
480
+ full=full,
481
+ make_hint=_search_hint,
482
+ )
483
+ extra: dict[str, Any] = {}
484
+ if page.hint is not None:
485
+ extra[HINT_KEY] = page.hint
486
+ emit_ok(page.payload, pretty=ctx.obj["pretty"], **meta, **page.meta, **extra)
487
+
488
+
489
+ @roadshow.command("bookings")
490
+ @click.option(
491
+ "--state",
492
+ metavar="BOOKED|ENDED",
493
+ help="BOOKED 已预约(含预告与直播中)/ ENDED 已结束;不传为全部未取消的预约。",
494
+ )
495
+ @click.option("--meeting-start", metavar="YYYY-MM-DD", help="会议开始日期下限。")
496
+ @click.option(
497
+ "--meeting-end",
498
+ metavar="YYYY-MM-DD",
499
+ help="会议开始日期上限,**含**当天全天(与 search 的 --end 语义相反)。",
500
+ )
501
+ @click.option("--book-start", metavar="YYYY-MM-DD HH:MM:SS", help="预约时间下限。")
502
+ @click.option("--book-end", metavar="YYYY-MM-DD HH:MM:SS", help="预约时间上限,含上限。")
503
+ @click.option("--page", "page_num", type=int, default=1, show_default=True, help="页码,从 1 开始。")
504
+ @click.option(
505
+ "--page-size", "page_size", type=int, default=20, show_default=True, help="每页条数。"
506
+ )
507
+ @_fields_options
508
+ @click.pass_context
509
+ def roadshow_bookings(
510
+ ctx: click.Context,
511
+ state: str | None,
512
+ meeting_start: str | None,
513
+ meeting_end: str | None,
514
+ book_start: str | None,
515
+ book_end: str | None,
516
+ page_num: int,
517
+ page_size: int,
518
+ fields_option: str | None,
519
+ full: bool,
520
+ ) -> None:
521
+ """查询我的路演预约(不含已取消的)。
522
+
523
+ \b
524
+ 默认按预约时间倒序。BOOKED 表示预告与直播中,**不代表此时一定能取消**;
525
+ 取消资格要按场次查:tytcli roadshow status --app-id <ID>
526
+
527
+ \b
528
+ 已下架会议的预约可能保留在结果里,此时 isAvailable 为 false、
529
+ unavailableReason 说明原因,标题与时间等字段可能整体省略。
530
+ """
531
+ selected = _resolve_fields(fields_option, full)
532
+ _validate_page(page_num, page_size)
533
+ state_value = _validate_state(state) if state is not None else None
534
+ for option, value in (("--meeting-start", meeting_start), ("--meeting-end", meeting_end)):
535
+ if value is not None:
536
+ _validate_iso_date(value, option)
537
+ for option, value in (("--book-start", book_start), ("--book-end", book_end)):
538
+ if value is not None:
539
+ _validate_datetime(value, option)
540
+ _validate_range(meeting_start, meeting_end, "--meeting-start", "--meeting-end")
541
+ _validate_range(book_start, book_end, "--book-start", "--book-end")
542
+
543
+ config = require_config()
544
+ client = _client(config)
545
+ envelope = client.roadshow_my_bookings(
546
+ page_num=page_num,
547
+ page_size=page_size,
548
+ booking_view_state=state_value,
549
+ meeting_start_date=meeting_start,
550
+ meeting_end_date=meeting_end,
551
+ book_start_time=book_start,
552
+ book_end_time=book_end,
553
+ )
554
+ _raise(envelope, config)
555
+
556
+ inner_raw = unwrap_data(envelope.payload)
557
+ meta: dict[str, Any] = {
558
+ "env": config.active_env,
559
+ "base_url": config.middle_base_url,
560
+ "api_root": "middleApi",
561
+ "http_status": envelope.http_status,
562
+ "page": page_num,
563
+ "page_size": page_size,
564
+ }
565
+ if state_value is not None:
566
+ meta["state"] = state_value
567
+
568
+ page = trim_page(
569
+ inner_raw=inner_raw,
570
+ list_key="infos",
571
+ default_fields=BOOKINGS_DEFAULT_FIELDS,
572
+ selected=selected,
573
+ full=full,
574
+ make_hint=_bookings_hint,
575
+ )
576
+ payload = page.payload
577
+ # 空页返回的 pageNum 是 0。把那个 0 当作下一页页码回传会被服务端拒绝,
578
+ # 所以这里改回报调用方自己请求的页码。
579
+ if isinstance(payload, dict):
580
+ mapping = cast("dict[str, Any]", payload)
581
+ if mapping.get("pageNum") == 0:
582
+ payload = {**mapping, "pageNum": page_num}
583
+ meta["empty_page_num_corrected"] = True
584
+ extra: dict[str, Any] = {}
585
+ if page.hint is not None:
586
+ extra[HINT_KEY] = page.hint
587
+ emit_ok(payload, pretty=ctx.obj["pretty"], **meta, **page.meta, **extra)
588
+
589
+
590
+ @roadshow.command("status")
591
+ @click.option("--app-id", required=True, metavar="ID", help="路演的 appId(来自 search 或 bookings)。")
592
+ @click.pass_context
593
+ def roadshow_status(ctx: click.Context, app_id: str) -> None:
594
+ """查询本人对某一场路演的预约状态与操作资格。
595
+
596
+ \b
597
+ 输出 isBooked / canBook / canCancel / reason,已预约时还有 bookTime。
598
+ reason 是服务端给出的中文原因,**不要**用它的文字做分支判断(同一种拒绝在
599
+ 预约与取消两条路径上的标点都不一样);按 canBook / canCancel 判断。
600
+
601
+ \b
602
+ 会议存在但当前不可操作时本命令仍然成功(ok:true),原因在 reason 里;
603
+ 会议不存在、已下架或 appId 不正确时才是失败。
604
+ """
605
+ target = _validate_app_id(app_id)
606
+ config = require_config()
607
+ client = _client(config)
608
+ envelope = client.roadshow_booking_status(app_id=target)
609
+ _raise(envelope, config)
610
+ data = _data_map(envelope) or {}
611
+ emit_ok(
612
+ data,
613
+ pretty=ctx.obj["pretty"],
614
+ env=config.active_env,
615
+ base_url=config.middle_base_url,
616
+ api_root="middleApi",
617
+ http_status=envelope.http_status,
618
+ app_id=target,
619
+ )
620
+
621
+
622
+ @roadshow.command("summary")
623
+ @click.option("--app-id", required=True, metavar="ID", help="路演的 appId(来自 search)。")
624
+ @_fields_options
625
+ @click.pass_context
626
+ def roadshow_summary(
627
+ ctx: click.Context, app_id: str, fields_option: str | None, full: bool
628
+ ) -> None:
629
+ """获取某一场路演的公开 AI 纪要。
630
+
631
+ \b
632
+ 默认输出简略版(id/meetingId/type/summary);--full 输出详细版全部字段,
633
+ 含 detail 正文与 companyOverview / productStrategy / fundPerformance /
634
+ marketOutlook / riskManagement / teamDivision / investHighlights /
635
+ mainRisks / qaPoints / timeline 等专题段落。
636
+
637
+ \b
638
+ 无需预约即可查询。**尚无公开纪要时 available 为 false**:该接口只返回审核
639
+ 通过的非草稿记录,所以"还没生成""待审核""草稿""已删除"四种情况在响应里
640
+ 无法区分——available:false 只表示"现在没有可读的公开纪要",不表示失败,
641
+ 也不表示纪要不存在过。
642
+ """
643
+ selected = _resolve_fields(fields_option, full)
644
+ target = _validate_app_id(app_id)
645
+
646
+ config = require_config()
647
+ client = _client(config)
648
+ envelope = client.roadshow_summary(app_id=target)
649
+ _raise(envelope, config)
650
+
651
+ data = _data_map(envelope)
652
+ content_present = bool(data) and any(
653
+ str(data.get(field) or "").strip() for field in SUMMARY_CONTENT_FIELDS
654
+ )
655
+ available = data is not None and content_present
656
+
657
+ meta: dict[str, Any] = {
658
+ "env": config.active_env,
659
+ "base_url": config.middle_base_url,
660
+ "api_root": "middleApi",
661
+ "http_status": envelope.http_status,
662
+ "app_id": target,
663
+ "available": available,
664
+ }
665
+
666
+ if not available:
667
+ # 说清楚为什么没有内容可显示,并说清楚**原因无法进一步缩小**——
668
+ # 一个把 available:false 读成"纪要内容为空"的 agent,会把它当成事实
669
+ # 汇报给用户。
670
+ meta["unavailable_note"] = (
671
+ "没有可读的公开 AI 纪要。该接口只返回审核通过的非草稿记录,因此"
672
+ "“尚未生成”“待审核”“草稿”“已删除”在响应中无法区分。"
673
+ )
674
+ emit_ok(None, pretty=ctx.obj["pretty"], **meta)
675
+ return
676
+
677
+ assert data is not None # available implies data
678
+ observed = observed_fields([data])
679
+ keys: list[str] = (
680
+ list(selected) if selected is not None
681
+ else (observed if full else list(SUMMARY_BRIEF_FIELDS))
682
+ )
683
+ meta["trimmed"] = not full
684
+ meta["record_fields"] = keys if not full else observed
685
+ unknown = [key for key in keys if key not in observed]
686
+ if unknown:
687
+ meta["unknown_fields"] = unknown
688
+ meta["available_fields"] = observed
689
+ extra: dict[str, Any] = {}
690
+ if selected is None and not full:
691
+ extra_fields = [field for field in observed if field not in SUMMARY_BRIEF_FIELDS]
692
+ parts = [f"默认仅输出简略版:{', '.join(SUMMARY_BRIEF_FIELDS)}。"]
693
+ if extra_fields:
694
+ parts.append(f"该纪要本次还返回了:{', '.join(extra_fields)}。")
695
+ parts.append("用 --full 取详细版(含 detail 正文与全部专题段落)。")
696
+ extra[HINT_KEY] = "".join(parts)
697
+ emit_ok(select_fields(data, keys), pretty=ctx.obj["pretty"], **meta, **extra)
698
+
699
+
700
+ # --------------------------------------------------------------------------- #
701
+ # writes
702
+ # --------------------------------------------------------------------------- #
703
+
704
+
705
+ def _write_flow(
706
+ ctx: click.Context,
707
+ *,
708
+ app_id: str,
709
+ yes: bool,
710
+ action: str,
711
+ ) -> None:
712
+ """``book`` 与 ``cancel`` 共用的 预检 / 写入 / 回读 流程。
713
+
714
+ ``action`` 取 ``"book"`` 或 ``"cancel"``。两个写命令合用一个函数是刻意的:
715
+ 顺序保证(绝不在无预检的情况下写入、写入后必定回读、超时必定对账)正是这套
716
+ 设计的全部意义,而两份实现必然漂移。
717
+ """
718
+ target = _validate_app_id(app_id)
719
+ config = require_config()
720
+ client = _client(config)
721
+
722
+ meta: dict[str, Any] = {
723
+ "env": config.active_env,
724
+ "base_url": config.middle_base_url,
725
+ "api_root": "middleApi",
726
+ "app_id": target,
727
+ "action": action,
728
+ }
729
+
730
+ before = _status(client, config, target)
731
+ is_booked = bool(before.get("isBooked"))
732
+ can_book = bool(before.get("canBook"))
733
+ can_cancel = bool(before.get("canCancel"))
734
+ reason = str(before.get("reason") or "")
735
+ meta["http_status"] = None # filled in per call below
736
+
737
+ if action == "book":
738
+ allowed, already = can_book, is_booked
739
+ already_key, not_allowed_code, verb = (
740
+ "already_booked", "ROADSHOW_NOT_BOOKABLE", "预约"
741
+ )
742
+ else:
743
+ # 取消一个本来就没预约的会议是"空操作成功",不是错误:服务端也接受
744
+ # (已实测),而拒绝它会让"超时后重试"这条路走不通。
745
+ allowed, already = can_cancel, not is_booked
746
+ already_key, not_allowed_code, verb = (
747
+ "already_cancelled", "ROADSHOW_NOT_CANCELLABLE", "取消预约"
748
+ )
749
+
750
+ if not yes:
751
+ emit_ok(
752
+ {
753
+ "would_write": bool(allowed and not already),
754
+ "preflight": before,
755
+ "next_step": (
756
+ f"确认无误后加 --yes 执行{verb}"
757
+ if allowed and not already
758
+ else f"当前不可{verb}:{reason or '服务端未给出原因'}"
759
+ ),
760
+ },
761
+ pretty=ctx.obj["pretty"],
762
+ written=False,
763
+ dry_run=True,
764
+ **meta,
765
+ )
766
+ return
767
+
768
+ if already:
769
+ emit_ok(
770
+ {
771
+ "status": before,
772
+ "note": f"已处于目标状态,未发起写入(服务端本身对重复{verb}也是幂等的)。",
773
+ },
774
+ pretty=ctx.obj["pretty"],
775
+ written=False,
776
+ **{already_key: True},
777
+ **meta,
778
+ )
779
+ return
780
+
781
+ if not allowed:
782
+ raise ApiError(
783
+ f"当前不可{verb}:{reason or '服务端未给出原因'}",
784
+ code=not_allowed_code,
785
+ details={**meta, "reason": reason, "preflight": before},
786
+ )
787
+
788
+ call = client.roadshow_book if action == "book" else client.roadshow_cancel_booking
789
+ try:
790
+ envelope = call(app_id=target)
791
+ except NetworkError as exc:
792
+ # 超时**不是**失败的证据。把真相回读出来再汇报,这样 agent 就不会
793
+ # 去重试,从而造成重复预约。
794
+ try:
795
+ after = _status(client, config, target)
796
+ except Exception:
797
+ # 重新抛出传输错误,而不是回读失败:调用方需要知道的是
798
+ # "这次写入结果未知"。
799
+ raise exc from None
800
+ succeeded = bool(after.get("isBooked")) if action == "book" else not after.get("isBooked")
801
+ if succeeded:
802
+ emit_ok(
803
+ {
804
+ "status": after,
805
+ "note": "写入请求超时,但回读确认操作已生效。",
806
+ },
807
+ pretty=ctx.obj["pretty"],
808
+ written=True,
809
+ recovered_after_timeout=True,
810
+ **meta,
811
+ )
812
+ return
813
+ exc.details = {
814
+ **(exc.details or {}),
815
+ **meta,
816
+ "reconciled_status": after,
817
+ "hint": "写入结果未知且回读显示未生效,可安全重试。",
818
+ }
819
+ raise
820
+
821
+ try:
822
+ _raise(envelope, config)
823
+ except ApiError as exc:
824
+ # 被拒绝的**写入**不等于请求格式有问题。服务端的 ErrorCode -1 既表示
825
+ # "地址或方法不对",也表示"这场路演已经结束",只有中文报文能区分二者——
826
+ # 这正是共享的信封映射无法区分它们的原因。而这一层知道自己刚调的是哪个
827
+ # 接口,所以可以诚实地命名这个失败,而不是告诉调用方"你的请求有问题"。
828
+ raise ApiError(
829
+ exc.message,
830
+ code="ROADSHOW_WRITE_REJECTED",
831
+ details={
832
+ **meta,
833
+ "reason": exc.message,
834
+ "server_error_code": envelope.error_code,
835
+ "mapped_from": exc.code,
836
+ "http_status": envelope.http_status,
837
+ },
838
+ ) from exc
839
+ meta["http_status"] = envelope.http_status
840
+ after = _status(client, config, target)
841
+ emit_ok(
842
+ {
843
+ "result": unwrap_data(envelope.payload),
844
+ "confirmed": after,
845
+ },
846
+ pretty=ctx.obj["pretty"],
847
+ written=True,
848
+ http_status=envelope.http_status,
849
+ **{k: v for k, v in meta.items() if k != "http_status"},
850
+ )
851
+
852
+
853
+ @roadshow.command("book")
854
+ @click.option("--app-id", required=True, metavar="ID", help="路演的 appId(来自 search)。")
855
+ @click.option(
856
+ "--yes",
857
+ is_flag=True,
858
+ help="真正执行预约。不带此参数时只做只读预检,不发起写入。",
859
+ )
860
+ @click.pass_context
861
+ def roadshow_book(ctx: click.Context, app_id: str, yes: bool) -> None:
862
+ """预约一场路演(写操作)。
863
+
864
+ \b
865
+ **默认不写入**:不带 --yes 时只查询本人预约资格并报告将会发生什么。
866
+ 带上 --yes 才会真正预约,且预约成功后回读状态确认。
867
+
868
+ \b
869
+ 只有公开可预约的预告场次能预约;开始时间不明、直播中、已结束、回放或已下架
870
+ 都会被拒绝。距离开始 15 分钟以内也不能预约。重复预约是幂等的:服务端返回
871
+ 成功且预约时间不变,本命令则会直接跳过写入。
872
+ """
873
+ _write_flow(ctx, app_id=app_id, yes=yes, action="book")
874
+
875
+
876
+ @roadshow.command("cancel")
877
+ @click.option("--app-id", required=True, metavar="ID", help="路演的 appId(来自 search 或 bookings)。")
878
+ @click.option(
879
+ "--yes",
880
+ is_flag=True,
881
+ help="真正执行取消。不带此参数时只做只读预检,不发起写入。",
882
+ )
883
+ @click.pass_context
884
+ def roadshow_cancel(ctx: click.Context, app_id: str, yes: bool) -> None:
885
+ """取消本人对一场路演的预约(写操作)。
886
+
887
+ \b
888
+ **默认不写入**:不带 --yes 时只查询本人预约资格并报告将会发生什么。
889
+ 带上 --yes 才会真正取消,且取消成功后回读状态确认。
890
+
891
+ \b
892
+ 只有可见的预告场次、且距离开始大于 15 分钟时才能取消。取消后不再出现在
893
+ bookings 里,也不再订阅后续的纪要提醒。取消用 appId,不是 bookingId。
894
+ """
895
+ _write_flow(ctx, app_id=app_id, yes=yes, action="cancel")
896
+
897
+
898
+ __all__ = ["roadshow"]